Compare commits

...
29 Commits
Author SHA1 Message Date
efrilmandClaude Opus 5.5 0c4dd72583 feat(loyalty): one-time EnakPoint payment code
Adds POST /customer/wallet/payment-code (docs/prd-point-coin.md F9, K8,
PC-304). The customer approves with their PIN on their own phone and gets a
6-digit code, as digits and as a QR payload (enakpoint:<code>) for the app
to render, valid for two minutes. The PIN is never typed at the cashier.

Codes are drawn from crypto/rand and stored in Redis with SET NX and a TTL,
bound to the customer; a new code retires the previous one. Redeeming is a
single Lua step that uses the code up only if it belongs to the order's
customer, so it stays one-time under a race, and a cashier scanning it
against the wrong order does not burn it for its owner, which a plain
GETDEL would. Expired, used, unknown and other customers' codes are all
refused alike.

Tests run against miniredis, added as a test dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:37:20 +07:00
efrilmandClaude Opus 5.5 cf5332c281 feat(loyalty): EnakPoint payment method
Adds the system payment method for paying with EnakPoint
(docs/prd-point-coin.md F9, §8, §10.5, PC-303).

Migration 000094 allows the point type, keeps one per organization with a
partial unique index, creates it for every existing organization, and adds
a trigger that creates it for new ones, as the walk-in customer is. It adds
payments.points_used and point_value. Their CHECK is written so it can
never be NULL: the PRD form, (both NULL) OR (both > 0), is NULL for
points_used with a NULL point_value, which a CHECK lets through, so a
payment could have lost the value a refund depends on. A test caught it.

The API cannot create, delete or retype the EnakPoint method, nor turn
another method into one; that answers 400. Renaming it is allowed. The
method list takes the outlet from ?outlet_id= or the user's outlet and
leaves EnakPoint out when that outlet does not accept it, filtered in the
query so the count stays right. The organization-wide active list is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:33:25 +07:00
efrilmandClaude Opus 5.5 fe2f459b03 feat(loyalty): organization loyalty settings API
Adds GET and PUT /marketing/loyalty-settings and GET
/marketing/loyalty-settings/history (docs/prd-point-coin.md F2, PC-302).

The settings are the point value, the exchange rate, transfer limits and
the stored expiry settings. PUT merges the body like the outlet settings
and is limited to loyalty managers. Every response carries the impact of
the change on the balances in circulation: outstanding EnakPoint and
EnakCoin, their rupiah value, and the coins exchanged into points, before
and after. With ?dry_run=true nothing is saved and the response lists the
keys that would change, for the warning shown before saving.

Saving records each change in loyalty_setting_changes with who made it;
history can be filtered to one outlet. Changing the value leaves what was
already written alone. The diff behind saving and previewing is shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:26:16 +07:00
efrilmandClaude Opus 5.5 8370851ed2 feat(loyalty): customer PIN
Adds the 6-digit customer PIN that approves every action moving EnakPoint
or EnakCoin on the customer's request (docs/prd-point-coin.md K8, F11, Q16,
Q17, PC-301).

Migration 000093 adds the PIN columns to customers and the
customer_security_events table. PIN data is read and written only through
CustomerPinRepository, never the Customer entity, so the hash cannot reach
a customer response. Only a bcrypt hash is stored.

- /customer/pin: status, OTP (pin_setup, pin_reset), create, change,
  reset. The OTP must be for that purpose and sent to the customer's own
  number; the existing OTP validation checks neither. A new PIN is checked
  (6 digits, confirmed, not one digit, not a run up or down, not the birth
  date as DDMMYY or YYMMDD) before the OTP is spent.
- Five wrong attempts in a row lock the PIN for 30 minutes; the counter is
  incremented in one statement so attempts at the same time all count,
  and a lock that ran out starts a new series. A locked PIN is refused even
  when right. The customer is told by WhatsApp, as there is no push channel
  to customers yet; only the attempt that reached the limit alerts.
- A reset through OTP lifts the lock and holds outgoing transfers for 24
  hours; paying and exchanging still work, and a held transfer costs no
  attempt.
- VerifyPin(ctx, customer, pin, action) for the flows that follow, with
  PIN_NOT_SET, PIN_INVALID (attempts left), PIN_LOCKED and
  TRANSFER_BLOCKED (until when), which PinErrorResponse turns into
  distinct codes and statuses.
- DELETE /marketing/customers/:id/pin (loyalty managers, reason required)
  and GET /marketing/customers/:id/security-events, scoped to the
  organization.

Every PIN event is in the security log with IP and user agent. No message
or binding error contains a PIN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 11:20:29 +07:00
efrilmandClaude Opus 5.5 fc97c78300 feat(loyalty): show what an order earned on the order and receipt
Adds points_earned and coins_earned to the order response
(docs/prd-point-coin.md F3, PC-205). The POS prints the receipt from this
response, so the receipt gets them too.

The values are the sums of the order's EARN rows, read in one query for a
list of orders. They are filled for create, add items, update, detail and
list, and are 0 for an order that earned nothing. UpdateOrder earns before
building its response, so a payment completed there already shows the
earning. A failure to read them is logged and leaves them at 0 rather than
failing the order read. The self-order session listing reads orders
directly from the repository and still shows 0.

The two order hooks are merged into one OrderLoyalty interface (paid,
refunded, earned by orders) with a single SetLoyalty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:50:37 +07:00
efrilmandClaude Opus 5.5 eb5b63677f feat(loyalty): take back earning when an order is voided or refunded
Adds earning reversal (docs/prd-point-coin.md F10, Q3, PC-204).

VoidOrder, RefundOrder and RefundPayment now end with an onOrderRefunded
hook, called once their writes have committed and, like onOrderPaid,
detached from the request so it can never block or fail the void or
refund. For RefundPayment that is after its transaction.

EarningProcessor.ReverseForOrder computes how much of each EARN row should
have come back in total: everything for a void, otherwise
floor(earned × refunded / basis) with the order's cumulative refund and
the basis frozen on the EARN row, never more than was earned (a refund
including tax can pass the basis). It takes only what has not been asked
back yet, what was taken plus any shortfall, so repeats and successive
partial refunds never add up to more than the earning. It writes an
EARN_REVERSAL pointing at the EARN with DebitUpTo, drawing from the lots
the EARN created first, and records the shortfall when the balance was
already spent.

When the balance is empty there is no ledger row to carry the shortfall;
that case is logged. VoidOrder still refuses fully paid orders, so a void
has nothing to take back today; the hook keeps it correct if that changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:45:33 +07:00
efrilmandClaude Opus 5.5 78c0c11774 feat(loyalty): earn EnakPoint and EnakCoin when an order is paid
Adds earning at payment time (docs/prd-point-coin.md F3, PC-203).

An order becomes fully paid through UpdateOrder, CreatePayment and both
kinds of split bill. All of them now go through one OrderProcessorImpl
hook, onOrderPaid, called after the payment has committed; for
CreatePayment that is after its transaction, not from updateOrderStatus
inside it. The hook runs detached from the caller's transaction and from
the request being cancelled, and it runs synchronously so the order
response can show what was earned.

EarningProcessor.EarnForOrder skips orders that are not paid, are void,
have no customer, or whose customer is the walk-in customer or inactive.
It computes the earning with CalculateEarning, subtracting any part paid
with EnakPoint (none until phase 3), and credits each currency through the
wallet engine as EARN with key earn:{order_id}:{currency} and the settings
snapshot in metadata. A repeat, even concurrent, credits nothing more.
OnOrderPaid never fails the payment: errors and panics are logged.

EarningBackfillJob is the safety net: every 30 minutes it earns for orders
paid in the last three days that have no EARN row. It only looks at
outlets with earning switched on and pages by (updated_at, id), so orders
that correctly earned nothing cannot starve the ones that were missed.

Lots from earning never expire until the expiry model is decided (F12,
note N4). Adds the point payment method type constant, not yet accepted as
a payment method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:40:05 +07:00
efrilmandClaude Opus 5.5 5baed18b22 feat(loyalty): earning calculator
Adds CalculateEarning (docs/prd-point-coin.md F1, Q1, Q10, PC-202), a pure
function returning, per currency, the amount an order earns and the
settings that produced it, plus the basis:

  basis  = subtotal − discount − paid with EnakPoint   (never negative)
  amount = 0 below min_order_amount, else
           floor(basis / earn_per_amount) × earn_value, capped by max_per_order

Tax and anything added on top of the subtotal are not part of the basis,
and the part paid with EnakPoint earns nothing. Money is handled in whole
cents: in float64 some baskets divide to 4956.999… and a naive floor would
lose a point, which a test reproduces. Metadata() gives the snapshot the
EARN row will freeze.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:31:09 +07:00
efrilmandClaude Opus 5.5 2bd53ee4a4 feat(loyalty): outlet loyalty settings API
Adds GET and PUT /outlets/:id/loyalty-settings (docs/prd-point-coin.md F1,
PC-201) on top of the typed settings processor.

The response shows every setting with its default when unset, the
organization's point value, and the effective EnakPoint cashback
(earn_value × point_value / earn_per_amount), so an owner cannot misread
the scale. PUT applies the body on top of the current settings: fields left
out keep their value, null clears an optional limit, and unknown fields are
refused so a typo cannot be ignored silently. The read-only fields of the
GET response are accepted and ignored, so a client can send back what it
received. It returns the keys that changed. Values outside the F1 bounds
answer 400, and an outlet of another organization 404.

RequireAdminOrManager also lets the purchasing role through, so loyalty
settings and the manual wallet adjustment from PC-107 now use a stricter
RequireLoyaltyManager (superadmin, admin, manager, owner).

Adds a test that registers every route, since gin panics at startup when
two routes name the same path parameter differently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:28:40 +07:00
efrilmandClaude Opus 5.5 39e47ff0e6 feat(loyalty): typed loyalty settings with change history
Adds LoyaltySettingsProcessor (docs/prd-point-coin.md F1, F2, F12, PC-109).

Reading returns typed settings for an outlet (earning per currency, paying
with EnakPoint) and for an organization (point value, exchange rate,
transfers, and the expiry settings awaiting note N4). A key that was never
set takes the PRD default. A stored value that is unusable, such as an
earn_per_amount of 0 that would divide by zero, also falls back to the
default and is logged, so a bad row never reaches a calculation.

Writing takes the whole settings struct, validates every rule in the PRD
before touching the database, and stores and records in
loyalty_setting_changes only the keys whose effective value changes: old
value (NULL while it was on its default), new value, and who changed it.
Clearing a limit deletes the stored value. Each save runs in one
transaction under an advisory lock per outlet or organization, so two saves
at once cannot both compute their change from the same old value. The
outlet must belong to the caller's organization.

Every key is described once (key, default, valid range, bound field), and
reading, validating and diffing all use that description.

GET /customer/wallet now reads the point value through this processor; the
minimal organization settings repository from PC-106 is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:23:08 +07:00
efrilmandClaude Opus 5.5 040780cd2d feat(wallet): reconcile balances, ledger and lots on a schedule
Adds the reconciliation of docs/prd-point-coin.md §7.5 (PC-108). One
aggregate query per check, across every wallet:

- wallet balance = SUM(ledger), per currency, including customers with
  ledger rows but no wallet row
- wallet balance = SUM(lot remaining)
- lot original - SUM(allocations) = remaining
- SUM(allocations) = |amount| for every deduction
- lots created = amount for every addition, which the engine keeps and the
  other checks rely on

The check on payments.points_used waits for that column (PC-305).

WalletReconciliationJob runs the checks at startup and every six hours,
alongside the omset scheduler. It is silent while the data is consistent.
Each discrepancy is logged with its check, customer, object and the
expected and actual values, and the organization's admins, owners and
managers get a high-priority notification. An organization is notified
again only when its set of discrepancies changes. Nothing is corrected
automatically. At most 50 discrepancies per check are reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:13:46 +07:00
efrilmandClaude Opus 5.5 a6d5a8b056 feat(wallet): customer wallet and manual adjustments in the dashboard
Adds the dashboard side of a customer's wallet (docs/prd-point-coin.md F7,
PC-107), under /marketing for admins and managers:

- GET /marketing/customers/:id/wallet returns the customer, the ledger and
  spendable balances, every lot that still holds something (flagged when
  expired), and a page of history. Unlike the customer's own view, each row
  carries the real names behind it: the transfer counterparty, the admin or
  cashier, and the outlet, plus the reason and metadata.
- POST /marketing/customers/:id/wallet/adjust takes a signed amount and a
  required reason. It writes an ADJUSTMENT pointing at the admin through the
  wallet engine, refuses to take more than the customer can spend, and
  accepts an optional idempotency key so a retried request adjusts once.
  Reasons describing a cash-out are refused (K7).

The customer must belong to the caller's organization; otherwise both
endpoints answer 404. Positive adjustments create non-expiring lots until
the expiry model is decided (F12, note N4).

The mapping from ledger rows to what the apps show is now shared between the
customer and dashboard views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:36:18 +07:00
efrilmandClaude Opus 5.5 e5db0325cc feat(wallet): serve customer balances and history from the wallet
GET /customer/wallet now reads the EnakPoint & EnakCoin wallet
(docs/prd-point-coin.md F6, PC-106): spendable point and coin balances, the
rupiah value of one EnakPoint and of the balance, the nearest day each
currency loses balance (grouped by Asia/Jakarta day), and recent ledger
rows. The fields of the pre-wallet response stay, filled from the wallet, so
app versions that read them keep working.

Adds GET /customer/wallet/transactions with pagination and filters for
currency, one or more types, and an inclusive date range. Each row shows
where the value came from (additions) or went to (deductions) as in §8.1,
and additions list their lots and earliest expiry. The counterparty id, the
admin and the metadata are left out; the description already carries the
masked name. A malformed query answers 400, a missing customer 404.

/customer/points and /customer/tokens keep their shape and now read the
wallet too, so customer_points_repository is no longer used for balances.

Balances are what the customer can spend: lots that have expired but that
the expiry job has not processed are not counted. The point value is read
from organization_settings (loyalty.point.value, default 1) through a small
repository that the typed settings reader in PC-109 will build on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:15:26 +07:00
efrilmandClaude Opus 5.5 41b75810fd feat(wallet): migrate legacy points and tokens into the wallet
Adds cmd/wallet-migrate (make wallet-migrate, args=-dry-run to only report),
which moves customer_points and customer_tokens into the wallet
(docs/prd-point-coin.md §10, PC-105). Each customer gets a MIGRATION ledger
row and a non-expiring lot per currency, written through WalletProcessor in
one transaction per customer. EnakCoin is the sum of every token type (Q6),
with the legacy rows listed in the row's metadata.

It credits the difference between the legacy balance and what earlier runs
migrated, so running it again never doubles a balance and picks up only
what the old code added since. A legacy balance that shrank after being
migrated is reported and left alone, since only an admin adjustment may
take balance away, and the command then exits non-zero. It ends with a
legacy / migrated / wallet total per currency.

Migration 000092 renames TOKENS to COINS in campaigns.type and
campaign_rules.reward_type. The campaign API now validates COINS; it still
accepts TOKENS, including as a list filter, and stores it as COINS so older
dashboards keep working while they are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:47:48 +07:00
efrilmandClaude Opus 5.5 6af97f5696 fix(wallet): stop logging new idempotency keys as errors
GetTransactionByIdempotencyKey used First, so every wallet operation with a
key not seen before, which is the normal case, logged a "record not found"
error. It now uses Find with a limit and returns nil when nothing matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:47:37 +07:00
efrilmandClaude Opus 5.5 2eb590caab feat(wallet): add wallet engine as the only way to change a balance
WalletProcessor writes the balance, the ledger row and the lots or
allocations together, which keeps SUM(ledger) = balance = SUM(lot
remaining) (docs/prd-point-coin.md §7.5, PC-104).

- Credit writes the ledger row and creates lots, each with its own expiry
  and origin lot.
- Debit draws from the preferred lots first (a reversal's own lots, or the
  lot being expired), then from unexpired lots in K9 order, and returns the
  allocations with their expiry so CarryOver can give the receiving side of
  a transfer or exchange the same expiry.
- DebitUpTo takes what the wallet has and reports the shortfall (F10, Q3).
- An idempotency key returns the first result; reusing it for a different
  operation is an error.
- §8.1 is checked in code from one rule table, ahead of the database
  constraints, so callers get a readable error.

Each method locks the wallet itself, after validating the input and before
checking the idempotency key, so correctness does not depend on the caller.
Operations on two wallets still call LockWallets first to keep lock order.

Unit tests run on an in-memory repository and check the §7.5 invariants
after every scenario; one more test runs the engine against Postgres when
TEST_DATABASE_URL is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:47:37 +07:00
efrilmandClaude Opus 5.5 fc5eecb68a feat(wallet): add wallet entities and repository
Entities for the four wallet tables and a WalletRepository that the wallet
processor will build on (PC-103).

Every method goes through the caller's transaction, and writes and locks
refuse to run without one: outside a transaction a lock is released as soon
as it is taken and a balance could move without its ledger row.

- LockWallet creates the wallet on first use, taking the organization from
  the customer, then locks it with SELECT ... FOR UPDATE.
- LockWallets always locks in customer_id order so opposite transfers
  cannot deadlock.
- AddBalance and ConsumeLot are conditional updates that return an error
  when they would overdraw, instead of tripping the CHECK constraint.
- ListActiveLots returns unexpired lots with balance in K9 spending order.

The tests need a real Postgres and run only when TEST_DATABASE_URL points at
a migrated database. Both the lock and the lock ordering were checked by
removing them and watching the tests fail (lost update, deadlock detected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:01:03 +07:00
efrilmandClaude Opus 5.5 b107f4ef04 feat(settings): add organization settings and loyalty setting history
Migration 000091 creates organization_settings, a key-value store per
organization shaped like outlet_settings, for the loyalty settings that must
be the same in every outlet (point value, exchange rate, transfer limits,
expiry). Until now there was nowhere to keep organization-level settings.

Also creates loyalty_setting_changes, the append-only log of who changed
which loyalty setting from what to what (PRD F2), for both organization and
outlet settings (PC-102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:01:02 +07:00
efrilmandClaude Opus 5.5 84401cc708 feat(wallet): add wallet, ledger and lot tables
Migration 000090 creates customer_wallets, wallet_transactions, wallet_lots
and wallet_lot_allocations as specified in docs/prd-point-coin.md §8 (PC-101).

The CHECK constraints enforce K5 at the database: every ledger row names its
source or destination, PAYMENT and the other point-only types cannot carry
COIN, transfers need a counterparty, reversals need the row they reverse,
adjustments need an admin and a reason, and EXPIRE must point at a lot.
Balances and lot remainders cannot go negative, and a lot cannot hold more
than it was created with.

Beyond §8, adds idx_wallet_lot_allocations_lot_id: the primary key cannot
serve lookups by lot, which the reconciliation job needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:01:01 +07:00
aefril f0ff59fea7 Merge pull request 'fix(analytics): cost weighed lines by weight, not row count' (#31) from feature/weight-based-products into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/31
2026-09-06 18:07:46 +02:00
efrilmandClaude Opus 5 923c108690 fix(analytics): cost weighed lines by weight, not row count
A weight-based line is one weighing, so order_items.quantity is pinned to 1
while unit_price and unit_cost are per unit of weight. Analytics SQL was
multiplying and dividing per-unit rates by the raw quantity, costing a 4.2 ons
fish as a single ons: standard_hpp_total and moving_average_hpp_total came out
far too low across all four product reports, overstating gross profit, and
average_price and fifo_hpp_per_unit read per weighing while
standard_hpp_per_unit read per unit, so the three HPP figures in one row could
not be compared.

Adds billableQty and billableQtyNet as the single place that decides the
multiplier, mirroring entities.OrderItem.BillableQuantity.

quantity_sold and total_items stay as weighing counts; weight_sold already
carries the amount. revenue and fifo_hpp_total were already correct via
total_price/total_cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:06:31 +07:00
aefril 1edeaa0a48 Merge pull request 'fix(order): carry item weight through the contract-to-model transformer' (#30) from feature/weight-based-products into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/30
2026-09-06 17:46:07 +02:00
efrilmandClaude Opus 5 f2701882dc fix(order): carry item weight through the contract-to-model transformer
CreateOrderContractToModel and AddToOrderContractToModel copied every order
item field except Weight, so a weight sent by the client never reached the
processor and every weight-based line failed with "product ... is sold by
weight and requires a weight".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:44:55 +07:00
aefril c9311997a7 Merge pull request 'Feature/weight based products' (#29) from feature/weight-based-products into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/29
2026-09-06 17:13:51 +02:00
efrilmandClaude Opus 5 ebf666c004 feat(product): require a unit for weight-based products
A product sold by weight with no unit produces order lines with nothing to
print: the receipt would read "4,2" with no idea of what. Until now nothing
stopped that — the mistake only surfaced at the cashier.

Enforce it in two places, because neither alone sees the whole picture. On
create, the validator has everything it needs. On update, the request may
omit unit_id for a product that already has one, so the check runs in the
processor against the merged product: what is rejected is the end state, a
product sold by weight with no unit.

Also fixes two things this uncovered:

The struct tags on the product contracts are decorative — this validator is
hand-written and never calls validator.Struct — so `oneof=unit weight` was
never enforced, and an unknown sell_by was silently rewritten to "unit" by
the mapper. It is now rejected with a message that names the valid values.

The update validator's "at least one field" guard did not list unit_id,
sell_by or print_to_checker, so an update carrying only one of those was
turned away as an empty request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:29:34 +07:00
efrilmandClaude Opus 5 d3987c7114 docs(order): add weight-based product integration guide
Client-facing companion to the RFC, aimed at the POS Mobile and Backoffice
teams: endpoints and payloads for setting up a weight product, placing an
order, rendering the line, and voiding, refunding or splitting it.

Documents two gaps the teams have to work around rather than discover:
unit_id is not yet enforced when sell_by is "weight", so Backoffice must
require it in the form; and money rounds to 2 decimals rather than whole
rupiah, which is still an open decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:20:22 +07:00
efrilmandClaude Opus 5 992bb04816 feat(order): support weight-based products
Products like fish are sold per weighing (4.2 ons, 5.6 ons), which the
order line could not represent: quantity is INTEGER and prices are always
computed as quantity * unit_price.

Model one weighing as one order line. quantity stays INTEGER and keeps
meaning "how many items"; the measured amount goes into a new nullable
order_items.weight, and the line is priced weight * unit_price. Two
weighings of the same product are two lines, never merged into one.

Keeping quantity integral avoids float comparisons in void, refund and
split bill, where accumulated rounding error would silently misbehave —
"1.4 + 1.4 + 1.4" is not 4.2 in float64, which would leave a fully paid
split-bill item marked unpaid.

BillableQuantity() is now the single place that decides between weight
and count; every price and cost calculation goes through it. Missing one
would bill a 4.2 ons fish as a single ons — wrong money, no error.

Two database constraints back the design: a weighed line always carries a
positive weight, and its quantity is pinned to 1. The latter also makes
void all-or-nothing for weighed lines, so the row-splitting branch can
never produce a zero-weight remainder row.

Also wires product.unit_id through the API, which was previously not
settable at all, and corrects the misleading comment on the request's
unit_price field — that value has never been used; price always comes
from the database.

Design notes and the audit of every price multiplication site are in
docs/rfc-weight-based-products.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 17:14:42 +07:00
aefril 1e5573af75 Merge pull request 'feat: cash advance' (#28) from dev into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/28
2026-08-13 09:39:53 +02:00
aefril b42d141927 Merge pull request 'Dev' (#27) from dev into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/27
2026-08-11 17:46:43 +02:00
142 changed files with 13339 additions and 281 deletions
+8
View File
@@ -42,6 +42,7 @@ help:
@echo " migration-up Up migrations"
@echo " migration-up ENV=production Up migrations (production DB)"
@echo " migration-down Down last migration"
@echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet"
@echo
@echo " docker-up Up docker services"
@echo " docker-down Down docker services"
@@ -105,6 +106,13 @@ migration-down:
migration-force:
@migrate -database $(DB_URL) -path ./migrations force $(version)
# Move customer_points / customer_tokens into the wallet. Safe to run again.
# Pass args=-dry-run to only report.
.SILENT: wallet-migrate
wallet-migrate:
ENV_MODE=$(ENV) go run ./cmd/wallet-migrate $(args)
.SILENT: seeder-create
seeder-create:
@migrate create -ext sql -dir ./seeders -seq $(name)
+73
View File
@@ -0,0 +1,73 @@
// Command wallet-migrate moves customer_points and customer_tokens balances into the
// EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §10). Safe to run more than once:
// each run credits only what earlier runs have not.
//
// ENV_MODE=staging go run ./cmd/wallet-migrate -dry-run
// ENV_MODE=staging go run ./cmd/wallet-migrate
//
// It exits non-zero when the legacy totals and the migrated totals disagree.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"apskel-pos-be/config"
"apskel-pos-be/internal/db"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
func main() {
dryRun := flag.Bool("dry-run", false, "report what would be credited without writing anything")
batchSize := flag.Int("batch", 500, "customers read per batch")
flag.Parse()
cfg := config.LoadConfig()
pg, err := db.NewPostgres(cfg.Database)
if err != nil {
log.Fatal(err)
}
migrator := processor.NewWalletMigrationProcessor(
repository.NewWalletMigrationRepository(pg),
processor.NewWalletProcessor(repository.NewWalletRepository(pg)),
repository.NewTxManager(pg),
)
report, err := migrator.Run(context.Background(), *dryRun, *batchSize)
if err != nil {
log.Fatalf("wallet migration failed: %v", err)
}
verb := "credited"
if report.DryRun {
verb = "would credit"
}
t := report.Totals
fmt.Printf("customers scanned: %d\n", report.CustomersScanned)
fmt.Printf("EnakPoint: %s %d in %d rows\n", verb, report.PointsCredited, report.PointCredits)
fmt.Printf("EnakCoin: %s %d in %d rows\n", verb, report.CoinsCredited, report.CoinCredits)
fmt.Printf("\n%-10s %15s %15s %15s\n", "", "legacy", "migrated", "wallet balance")
fmt.Printf("%-10s %15d %15d %15d\n", "EnakPoint", t.LegacyPoints, t.MigratedPoints, t.WalletPoints)
fmt.Printf("%-10s %15d %15d %15d\n", "EnakCoin", t.LegacyCoins, t.MigratedCoins, t.WalletCoins)
if len(report.Discrepancies) > 0 {
fmt.Printf("\n%d balances shrank in the legacy tables after they were migrated.\n", len(report.Discrepancies))
fmt.Println("They were left as they are in the wallet; correct them with an adjustment if needed:")
for _, d := range report.Discrepancies {
fmt.Printf(" customer %s %s: legacy %d, migrated %d\n", d.CustomerID, d.Currency, d.Legacy, d.Migrated)
}
}
if report.DryRun {
return
}
if !report.Balanced() {
fmt.Println("\nNOT BALANCED: legacy and migrated totals differ")
os.Exit(1)
}
fmt.Println("\nbalanced: legacy totals = migrated totals")
}
+279
View File
@@ -0,0 +1,279 @@
# Integrasi Produk Timbangan — POS Mobile & Backoffice
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
---
## 1. Konsep inti
**Satu penimbangan = satu baris order.**
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
| Bukan begini | Melainkan begini |
|---|---|
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
Empat aturan yang berlaku di seluruh dokumen ini:
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
apa pun yang dipilih saat setup).
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
`unit_price` tetap berarti harga per satu satuan (per ons).
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
produknya sama.
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
cacah seperti sekarang.
---
## 2. Backoffice — setup produk
### 2.1 Pastikan satuannya ada
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
`POST /api/v1/units`
```json
{
"name": "Ons",
"abbreviation": "ons",
"is_active": true
}
```
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
### 2.2 Buat produk sebagai produk timbangan
`POST /api/v1/products`
```json
{
"category_id": "…",
"name": "Ikan Tude",
"price": 4500,
"cost": 3000,
"sell_by": "weight",
"unit_id": "<id satuan Ons>"
}
```
| Field | Tipe | Keterangan |
|---|---|---|
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
`/products/:id`).
### 2.3 Catatan UI
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
dikonsumsi apa pun di POS.
---
## 3. POS Mobile — transaksi
### 3.1 Bentuk input mengikuti `sell_by`
| `sell_by` | Input di POS | Yang dikirim |
|---|---|---|
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
### 3.2 Mengirim order
`POST /api/v1/orders`
```json
{
"outlet_id": "…",
"user_id": "…",
"order_type": "dine_in",
"order_items": [
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
{ "product_id": "<nasi-goreng>", "quantity": 2 }
]
}
```
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
mandiri `POST /api/v1/self-order/orders`.
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
bawah `0.001` membulat ke nol dan ditolak.
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
---
## 4. Menampilkan baris
Setiap `order_items[]` di response membawa empat field tambahan:
```json
{
"product_name": "Ikan Tude",
"quantity": 1,
"weight": 4.2,
"unit_id": "…",
"unit_name": "Ons",
"unit_abbreviation": "ons",
"unit_price": 4500,
"total_price": 18900
}
```
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
| Kondisi | Tampilkan |
|---|---|
| `weight == null` | `2 × Rp 25.000` |
| `weight != null` | `4,2 ons × Rp 4.500` |
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
desimal sesuai format Indonesia.
---
## 5. Void, refund, split bill
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
rupiahnya dari `weight`.
| Aksi | Endpoint | Field untuk baris berbobot |
|---|---|---|
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
```json
{
"order_id": "…",
"reason": "Salah timbang",
"type": "ITEM",
"items": [
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
]
}
```
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
tombol pilih baris.
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
sehingga jejak auditnya tetap jujur.
---
## 6. Referensi error
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
```json
{
"success": false,
"data": null,
"errors": [
{ "code": "900", "entity": "ORDER",
"cause": "product Ikan Tude is sold by weight and requires a weight" }
]
}
```
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|---|---|---|
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
### Setup produk (Backoffice)
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|---|---|---|
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
---
## 7. Kompatibilitas mundur
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
produk satuan.
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
parser lama tidak terganggu.
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
---
## 8. Batasan yang diketahui
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
sisa stok yang mengandaikan itu berjalan.
---
## 9. Checklist per tim
**Backoffice Website**
- [ ] CRUD satuan tersedia di menu master data
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
- [ ] Label harga ikut satuan — "Harga per ons"
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
- [ ] Daftar produk menandai mana yang dijual per timbangan
- [ ] Laporan harian menampilkan kolom Berat
**POS Mobile**
- [ ] Menu membaca `sell_by` tiap produk
- [ ] Papan angka desimal untuk produk timbangan
- [ ] Kirim `quantity: 1` + `weight`
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
- [ ] Void & refund baris berbobot bersifat utuh
- [ ] Split bill: pilih baris, bukan stepper jumlah
- [ ] Pesan error validasi ditampilkan ke kasir
+362
View File
@@ -0,0 +1,362 @@
# RFC: Produk Timbangan (Weight-Based Products)
**Status:** Diimplementasikan (migrasi `000089`)
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
**Scope:** Product, Order, Void/Refund, Report
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
---
## 1. Masalah
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
terpisah** — bukan satu baris berisi 9,8.
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
---
## 2. Keputusan Inti
**Satu penimbangan = satu baris `order_items`.**
| | Baris 1 | Baris 2 |
|---|---|---|
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
ikan yang berbeda.
### Kenapa bukan `quantity = 4.2`
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
yang berubah di dunia nyata.
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
---
## 3. Prinsip
**P1 — Baris transaksi adalah snapshot yang beku.**
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
mengubah arti transaksi yang sudah terjadi.
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
**P3 — Harga tetap otoritas backend.**
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
`product_outlet_prices` seperti sekarang.
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
satuan berbeda tidak bermakna.
---
## 4. Perubahan Skema
```sql
-- Products: cara jual
ALTER TABLE products
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
CHECK (sell_by IN ('unit', 'weight'));
-- Order items: berat + snapshot satuan
ALTER TABLE order_items
ADD COLUMN weight DECIMAL(12,3),
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
ALTER TABLE order_items
ADD CONSTRAINT chk_order_items_weight_positive
CHECK (weight IS NULL OR weight > 0),
ADD CONSTRAINT chk_order_items_weight_single_line
CHECK (weight IS NULL OR quantity = 1);
```
**Catatan:**
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
tanpa backfill, dan perilakunya tidak berubah sama sekali.
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
ambigu dan membuat void otomatis bersifat utuh (§6).
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
berlaku.
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
presisi sama.
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
---
## 5. Perhitungan Harga
Satu-satunya tempat yang boleh mengalikan harga (P2):
```go
// BillableQuantity mengembalikan pengali harga untuk baris ini:
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
func (oi *OrderItem) BillableQuantity() float64 {
if oi.Weight != nil {
return *oi.Weight
}
return float64(oi.Quantity)
}
func (oi *OrderItem) CalculateTotalPrice() {
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
}
func (oi *OrderItem) CalculateTotalCost() {
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
}
```
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
konversi yang menyelinap ke perhitungan uang.
### Titik yang harus diganti
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
bukan salah tampilan**, dan tidak memicu error apa pun.
| Lokasi | Sekarang |
|---|---|
| `processor/order_processor.go:197-198` | buat order |
| `processor/order_processor.go:330-331` | tambah item ke order |
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
| `processor/order_processor.go:723` | jumlah refund |
| `processor/split_bill_processor.go:143` | hitung jumlah split |
| `processor/split_bill_processor.go:189` | catat pembayaran |
| `processor/split_bill_processor.go:231` | metadata pembayaran |
| `repository/order_item_repository.go:113` | jumlah void penuh |
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
| Lokasi | Status |
|---|---|
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
dihitung sebagai 1 satuan resep.
### Verifikasi
```bash
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
```
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
- `mappers/inventory_movement_mapper.go:129` dan
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
baris order.
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
---
## 6. Void, Refund, Split Bill
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
integer.
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
berubah.
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
Ini menjaga jejak audit tetap jujur.
---
## 7. Validasi & Tampilan
### 7.1 Aturan validasi
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
| `products.sell_by` | Aturan |
|---|---|
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
bukan dibaca lewat join saat ditampilkan.
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
nilai yang divalidasi.
### 7.2 Tampilan
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
---
## 8. Report
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
"berapa banyak barang":
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
ekor", yang tetap merupakan angka bermakna.
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
(P4):
```sql
COALESCE(SUM(oi.weight), 0) AS weight_sold
```
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
berbeda.
---
## 9. Di Luar Scope
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
penjualan tidak tersambung.
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
apa pun.
Catatan untuk nanti bila jalur stok disambungkan:
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
---
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
`products` / `product_outlet_prices`.
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
berhasil, padahal diabaikan diam-diam.
---
## 11. Urutan Implementasi
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
tidak tersentuh.
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
response.
5. **Validasi `sell_by`** (§7.1).
6. **Template & tampilan struk** (§7.2).
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
---
## 12. Risiko
| Risiko | Dampak | Mitigasi |
|---|---|---|
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
---
## 13. Pertanyaan Terbuka
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
baru akan mengikuti aturan berbeda.
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
tambahkan `products.min_weight_increment`.
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
+2
View File
@@ -76,6 +76,7 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
@@ -107,6 +108,7 @@ require (
require (
firebase.google.com/go/v4 v4.19.0
github.com/alicebob/miniredis/v2 v2.39.0
github.com/aws/aws-sdk-go v1.55.7
github.com/boombuler/barcode v1.1.0
github.com/golang-jwt/jwt/v5 v5.2.3
+4
View File
@@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
@@ -349,6 +351,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+65 -3
View File
@@ -31,6 +31,8 @@ type App struct {
router *router.Router
shutdown chan os.Signal
omsetScheduler *service.OmsetMilestoneScheduler
walletRecon *service.WalletReconciliationJob
earningRetry *service.EarningBackfillJob
}
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
@@ -53,6 +55,15 @@ func (a *App) Initialize(cfg *config.Config) error {
processors.notificationProcessor,
)
// Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5)
a.walletRecon = service.NewWalletReconciliationJob(
repository.NewWalletReconciliationRepository(a.db),
repos.userRepo,
processors.notificationProcessor,
)
// Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3)
a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor)
services := a.initServices(processors, repos, cfg)
validators := a.initValidators()
middleware := a.initMiddleware(services, cfg)
@@ -142,6 +153,10 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.expenseValidator,
services.cashAdvanceService,
validators.cashAdvanceValidator,
services.walletAdminService,
validators.walletValidator,
services.loyaltySettingsService,
services.customerPinService,
a.redisClient,
)
@@ -153,6 +168,12 @@ func (a *App) Start(port string) error {
if a.omsetScheduler != nil {
a.omsetScheduler.Start(5 * time.Minute)
}
if a.walletRecon != nil {
a.walletRecon.Start(6 * time.Hour)
}
if a.earningRetry != nil {
a.earningRetry.Start(30 * time.Minute)
}
engine := a.router.Init()
@@ -192,6 +213,12 @@ func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
if a.walletRecon != nil {
a.walletRecon.Stop()
}
if a.earningRetry != nil {
a.earningRetry.Stop()
}
close(a.shutdown)
}
@@ -247,6 +274,9 @@ type repositories struct {
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
expenseRepo *repository.ExpenseRepositoryImpl
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
walletRepo repository.WalletRepository
walletQueryRepo repository.WalletQueryRepository
loyaltySettingsRepo repository.LoyaltySettingsRepository
}
func (a *App) initRepositories() *repositories {
@@ -302,6 +332,9 @@ func (a *App) initRepositories() *repositories {
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
walletRepo: repository.NewWalletRepository(a.db),
walletQueryRepo: repository.NewWalletQueryRepository(a.db),
loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db),
}
}
@@ -350,14 +383,29 @@ type processors struct {
productOutletPriceProcessor processor.ProductOutletPriceProcessor
expenseProcessor *processor.ExpenseProcessorImpl
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
walletProcessor *processor.WalletProcessor
walletAdminProcessor *processor.WalletAdminProcessor
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
earningProcessor *processor.EarningProcessor
customerPinProcessor *processor.CustomerPinProcessor
paymentCodeProcessor *processor.PaymentCodeProcessor
}
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config)
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)
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
// 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)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo),
@@ -367,8 +415,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
orderProcessor: orderProcessor,
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
@@ -394,7 +442,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
otpProcessor: otpProcessor,
fileClient: fileClient,
inventoryMovementService: inventoryMovementService,
@@ -403,6 +451,12 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
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),
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
loyaltySettingsProcessor: loyaltySettingsProcessor,
earningProcessor: earningProcessor,
customerPinProcessor: customerPinProcessor,
paymentCodeProcessor: paymentCodeProcessor,
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
}
}
@@ -445,6 +499,9 @@ type services struct {
productOutletPriceService service.ProductOutletPriceService
expenseService *service.ExpenseServiceImpl
cashAdvanceService *service.CashAdvanceServiceImpl
walletAdminService *service.WalletAdminServiceImpl
loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
@@ -526,6 +583,9 @@ 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),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
}
}
@@ -571,6 +631,7 @@ type validators struct {
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
expenseValidator *validator.ExpenseValidatorImpl
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
walletValidator *validator.WalletValidatorImpl
}
func (a *App) initValidators() *validators {
@@ -604,6 +665,7 @@ func (a *App) initValidators() *validators {
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
expenseValidator: validator.NewExpenseValidator(),
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
walletValidator: validator.NewWalletValidator(),
}
}
+20 -6
View File
@@ -12,6 +12,12 @@ const (
ValidationErrorCode = "304"
InvalidFieldErrorCode = "305"
NotFoundErrorCode = "404"
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
PinNotSetErrorCode = "PIN_NOT_SET"
PinInvalidErrorCode = "PIN_INVALID"
PinLockedErrorCode = "PIN_LOCKED"
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
TooManyRequestsErrorCode = "429"
)
const (
@@ -63,15 +69,23 @@ const (
ProductOutletPriceServiceEntity = "product_outlet_price_service"
ExpenseServiceEntity = "expense_service"
CashAdvanceServiceEntity = "cash_advance_service"
WalletServiceEntity = "wallet_service"
LoyaltySettingsServiceEntity = "loyalty_settings_service"
CustomerPinServiceEntity = "customer_pin_service"
)
var HttpErrorMap = map[string]int{
InternalServerErrorCode: http.StatusInternalServerError,
MissingFieldErrorCode: http.StatusBadRequest,
MalformedFieldErrorCode: http.StatusBadRequest,
ValidationErrorCode: http.StatusBadRequest,
InvalidFieldErrorCode: http.StatusBadRequest,
NotFoundErrorCode: http.StatusNotFound,
InternalServerErrorCode: http.StatusInternalServerError,
MissingFieldErrorCode: http.StatusBadRequest,
MalformedFieldErrorCode: http.StatusBadRequest,
ValidationErrorCode: http.StatusBadRequest,
InvalidFieldErrorCode: http.StatusBadRequest,
NotFoundErrorCode: http.StatusNotFound,
PinNotSetErrorCode: http.StatusForbidden,
PinInvalidErrorCode: http.StatusBadRequest,
PinLockedErrorCode: http.StatusLocked,
TransferBlockedErrorCode: http.StatusForbidden,
TooManyRequestsErrorCode: http.StatusTooManyRequests,
}
// Error messages
+71
View File
@@ -0,0 +1,71 @@
package constants
// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in
// outlet_settings and organization keys in organization_settings. A key that was
// never set takes the default in the PRD.
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
const (
LoyaltyPointEnabledKey = "loyalty.point.enabled"
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment"
LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points"
LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent"
)
// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and
// expiry, which must be the same in every outlet.
const (
LoyaltyPointValueKey = "loyalty.point.value"
LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount"
LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount"
LoyaltyTransferEnabledKey = "loyalty.transfer.enabled"
LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount"
LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction"
LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit"
// Suffixes after "loyalty.point." or "loyalty.coin.".
LoyaltyExpiryEnabledSuffix = "expiry_enabled"
LoyaltyExpiryPeriodSuffix = "expiry_period"
LoyaltyExpiryUnitSuffix = "expiry_unit"
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
)
// Units of loyalty.{point,coin}.expiry_unit.
const (
LoyaltyExpiryUnitDay = "DAY"
LoyaltyExpiryUnitMonth = "MONTH"
)
// Defaults from the PRD.
const (
LoyaltyPointEarnPerAmountDefault = int64(100)
LoyaltyCoinEarnPerAmountDefault = int64(25000)
LoyaltyEarnValueDefault = int64(1)
LoyaltyMinPaymentPointsDefault = int64(1)
LoyaltyMaxPaymentPercentDefault = int64(100)
LoyaltyPointValueDefault = int64(1)
LoyaltyExchangeAmountDefault = int64(1)
LoyaltyTransferEnabledDefault = true
LoyaltyTransferMinAmountDefault = int64(1)
LoyaltyExpiryPeriodDefault = int64(12)
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
LoyaltyExpiryReminderDaysDefault = int64(7)
)
+3
View File
@@ -8,6 +8,9 @@ const (
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
PaymentMethodTypeQR PaymentMethodType = "qr"
PaymentMethodTypeEDC PaymentMethodType = "edc"
// Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method
// type until that phase ships.
PaymentMethodTypePoint PaymentMethodType = "point"
)
type PaymentStatus string
+17
View File
@@ -0,0 +1,17 @@
package constants
// SellBy describes how a product's quantity is expressed on an order line.
type SellBy = string
const (
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
SellByUnit SellBy = "unit"
// SellByWeight means the line carries a weighed amount. One weighing is one line,
// so quantity stays 1 and the measured value lives in order_items.weight.
SellByWeight SellBy = "weight"
)
// IsValidSellBy reports whether s is a supported sell_by value.
func IsValidSellBy(s string) bool {
return s == SellByUnit || s == SellByWeight
}
+44
View File
@@ -0,0 +1,44 @@
package constants
// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays
// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint.
const (
WalletCurrencyPoint = "POINT"
WalletCurrencyCoin = "COIN"
)
func IsValidWalletCurrency(currency string) bool {
return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin
}
// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which
// way it moves the balance, and which reference it must carry.
const (
WalletTxTypeEarn = "EARN"
WalletTxTypeEarnReversal = "EARN_REVERSAL"
WalletTxTypePayment = "PAYMENT"
WalletTxTypePaymentRefund = "PAYMENT_REFUND"
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
WalletTxTypeExchangeIn = "EXCHANGE_IN"
WalletTxTypeTransferOut = "TRANSFER_OUT"
WalletTxTypeTransferIn = "TRANSFER_IN"
WalletTxTypeGameSpend = "GAME_SPEND"
WalletTxTypeExpire = "EXPIRE"
WalletTxTypeAdjustment = "ADJUSTMENT"
WalletTxTypeMigration = "MIGRATION"
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
)
// What a ledger row's reference_id points at: where the value came from for a
// credit, or where it went for a debit.
const (
WalletRefTypeOrder = "ORDER"
WalletRefTypePayment = "PAYMENT"
WalletRefTypeWalletTx = "WALLET_TX"
WalletRefTypeGamePlay = "GAME_PLAY"
WalletRefTypeLot = "LOT"
WalletRefTypeUser = "USER"
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
)
+2
View File
@@ -198,6 +198,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -324,6 +325,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+5 -5
View File
@@ -12,7 +12,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -0,0 +1,33 @@
package contract
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
// F11). PINs are strings so a leading zero is kept.
type RequestPinOtpRequest struct {
// pin_setup or pin_reset.
Purpose string `json:"purpose" binding:"required"`
}
type CreateCustomerPinRequest struct {
OtpToken string `json:"otp_token" binding:"required"`
OtpCode string `json:"otp_code" binding:"required"`
Pin string `json:"pin" binding:"required"`
ConfirmPin string `json:"confirm_pin" binding:"required"`
}
type ChangeCustomerPinRequest struct {
OldPin string `json:"old_pin" binding:"required"`
Pin string `json:"pin" binding:"required"`
ConfirmPin string `json:"confirm_pin" binding:"required"`
}
type ResetCustomerPinRequest = CreateCustomerPinRequest
type RemoveCustomerPinRequest struct {
Reason string `json:"reason" binding:"required"`
}
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
type IssuePaymentCodeRequest struct {
Pin string `json:"pin" binding:"required"`
}
+39 -27
View File
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
}
type CreateOrderItemRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type UpdateOrderItemRequest struct {
@@ -89,29 +92,38 @@ type OrderResponse struct {
TotalPaid float64 `json:"total_paid"`
PaymentCount int `json:"payment_count"`
SplitType *string `json:"split_type,omitempty"`
// EnakPoint and EnakCoin the order earned, for the order screen and the receipt
// (docs/prd-point-coin.md F3). Zero when it earned nothing.
PointsEarned int64 `json:"points_earned"`
CoinsEarned int64 `json:"coins_earned"`
}
type OrderItemResponse struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id"`
ProductVariantName *string `json:"product_variant_name,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
CategoryName *string `json:"category_name,omitempty"`
Quantity int `json:"quantity"`
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
Weight *float64 `json:"weight,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
UnitName *string `json:"unit_name,omitempty"`
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Modifiers []map[string]interface{} `json:"modifiers"`
Notes *string `json:"notes,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PrinterType string `json:"printer_type"`
PrintToChecker bool `json:"print_to_checker"`
PaidQuantity int `json:"paid_quantity"`
}
type ListOrdersQuery struct {
+8 -6
View File
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
type UpdatePaymentMethodRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
Configuration map[string]interface{} `json:"configuration,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -38,11 +38,13 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
IsActive *bool `json:"is_active,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
// When set, EnakPoint is left out unless the outlet accepts it (F9).
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
IsActive *bool `json:"is_active,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
}
type ListPaymentMethodsResponse struct {
+6
View File
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
PrintToChecker *bool `json:"print_to_checker,omitempty"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
@@ -73,6 +77,8 @@ type ProductResponse struct {
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
PrinterType string `json:"printer_type"`
UnitID *uuid.UUID `json:"unit_id,omitempty"`
SellBy string `json:"sell_by"`
PrintToChecker bool `json:"print_to_checker"`
Metadata map[string]interface{} `json:"metadata"`
IsActive bool `json:"is_active"`
+1
View File
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty"`
}
+14
View File
@@ -0,0 +1,14 @@
package contract
// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust
// (docs/prd-point-coin.md F7).
type AdjustCustomerWalletRequest struct {
// POINT or COIN.
Currency string `json:"currency"`
// Signed: positive adds to the balance, negative takes from it. Never zero.
Amount int64 `json:"amount"`
Reason string `json:"reason"`
// Optional. Sending the same key again returns the first result instead of
// adjusting twice, so a retried request is safe.
IdempotencyKey string `json:"idempotency_key"`
}
+1
View File
@@ -114,6 +114,7 @@ type ProductAnalytics struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+23 -2
View File
@@ -12,10 +12,31 @@ type CampaignType string
const (
CampaignTypeReward CampaignType = "REWARD"
CampaignTypePoints CampaignType = "POINTS"
CampaignTypeTokens CampaignType = "TOKENS"
CampaignTypeCoins CampaignType = "COINS"
CampaignTypeMixed CampaignType = "MIXED"
)
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
const legacyCampaignTokens = "TOKENS"
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
func NormalizeCampaignType(t string) CampaignType {
if t == legacyCampaignTokens {
return CampaignTypeCoins
}
return CampaignType(t)
}
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
// else as is.
func NormalizeCampaignRewardType(t string) CampaignRewardType {
if t == legacyCampaignTokens {
return CampaignRewardTypeCoins
}
return CampaignRewardType(t)
}
type RuleType string
const (
@@ -31,7 +52,7 @@ type CampaignRewardType string
const (
CampaignRewardTypePoints CampaignRewardType = "POINTS"
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
CampaignRewardTypeCoins CampaignRewardType = "COINS"
CampaignRewardTypeReward CampaignRewardType = "REWARD"
)
+18
View File
@@ -0,0 +1,18 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
}
+6
View File
@@ -44,6 +44,12 @@ func GetAllEntities() []interface{} {
&ProductOutletPrice{},
&Expense{},
&CashAdvance{},
// Wallet entities
&CustomerWallet{},
&WalletTransaction{},
&WalletLot{},
&WalletLotAllocation{},
&LoyaltySettingChange{},
}
}
@@ -0,0 +1,34 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// LoyaltySettingChange records one change to a loyalty setting: which key, from what,
// to what, by whom (docs/prd-point-coin.md F2). Append-only.
type LoyaltySettingChange struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
// Nil for an organization setting.
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
Key string `gorm:"not null;size:100" json:"key"`
// Nil when the key had no stored value, or has none any more.
OldValue *string `gorm:"type:text" json:"old_value"`
NewValue *string `gorm:"type:text" json:"new_value"`
ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error {
if c.ID == uuid.Nil {
c.ID = uuid.New()
}
return nil
}
func (LoyaltySettingChange) TableName() string {
return "loyalty_setting_changes"
}
+54 -1
View File
@@ -4,6 +4,7 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"math"
"time"
"github.com/google/uuid"
@@ -47,6 +48,8 @@ type OrderItem struct {
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
@@ -68,6 +71,7 @@ type OrderItem struct {
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
}
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
return "order_items"
}
// IsWeighed reports whether this line was sold by weight rather than by count.
func (oi *OrderItem) IsWeighed() bool {
return oi.Weight != nil
}
// BillableQuantity is the multiplier for unit price and unit cost on this line:
// the weighed amount for weight-based products, the item count otherwise.
//
// This is the ONLY place that decides between the two. Every price or cost
// calculation must go through it — multiplying by Quantity directly would bill a
// 4.2 ons fish as a single ons.
//
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
// so there is no ambiguity between counting and weighing.
func (oi *OrderItem) BillableQuantity() float64 {
if oi.Weight != nil {
return *oi.Weight
}
return float64(oi.Quantity)
}
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
// voiding or refunding `units` of it.
//
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
// covers the whole weighing — hence the full weight is returned regardless of units.
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
if oi.Weight != nil {
return *oi.Weight
}
return float64(units)
}
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
// value written back always matches what the database keeps.
func RoundMoney(amount float64) float64 {
return math.Round(amount*100) / 100
}
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
// is identical to the stored one.
func RoundWeight(weight float64) float64 {
return math.Round(weight*1000) / 1000
}
func (oi *OrderItem) CalculateTotalPrice() {
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
}
func (oi *OrderItem) CalculateTotalCost() {
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
}
func (oi *OrderItem) CanBeModified() bool {
+104
View File
@@ -0,0 +1,104 @@
package entities
import "testing"
func weightPtr(v float64) *float64 { return &v }
func TestBillableQuantity(t *testing.T) {
tests := []struct {
name string
item OrderItem
want float64
}{
{"unit line counts items", OrderItem{Quantity: 3}, 3},
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.item.BillableQuantity(); got != tt.want {
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
}
})
}
}
// A weighed line is voided or refunded whole, so the units asked for must not shrink
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
if got := weighed.BillableQuantityFor(1); got != 5.6 {
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
}
counted := OrderItem{Quantity: 3}
if got := counted.BillableQuantityFor(2); got != 2 {
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
}
}
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
// must not be billed as a single ons.
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 18900 {
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
}
if item.TotalCost != 12600 {
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
}
}
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 75000 {
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
}
if item.TotalCost != 30000 {
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
}
}
// Sub-rupiah residue must not survive into a decimal(10,2) column.
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
item.CalculateTotalPrice()
if item.TotalPrice != 19066.5 {
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
}
}
func TestIsWeighed(t *testing.T) {
if (&OrderItem{Quantity: 1}).IsWeighed() {
t.Error("unit line reported as weighed")
}
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
t.Error("weighed line reported as unit")
}
}
func TestRoundWeight(t *testing.T) {
tests := []struct {
in float64
want float64
}{
{4.2, 4.2},
{4.2374, 4.237},
{4.2375, 4.238},
{0.0004, 0},
}
for _, tt := range tests {
if got := RoundWeight(tt.in); got != tt.want {
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}
+14 -7
View File
@@ -13,13 +13,16 @@ const (
PaymentMethodTypeCash PaymentMethodType = "cash"
PaymentMethodTypeCard PaymentMethodType = "card"
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
// organization; it cannot be created, deleted or retyped through the API.
PaymentMethodTypePoint PaymentMethodType = "point"
)
type PaymentMethod struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
Processor *string `gorm:"size:100" json:"processor"`
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
IsActive bool `gorm:"default:true" json:"is_active"`
@@ -69,12 +72,16 @@ type Payment struct {
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
// one then, frozen so a refund returns exactly what was used.
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
+1
View File
@@ -20,6 +20,7 @@ type Product struct {
ImageURL *string `gorm:"size:500" json:"image_url"`
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IsActive bool `gorm:"default:true" json:"is_active"`
+113
View File
@@ -0,0 +1,113 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also
// the lock every wallet operation for the customer takes first, so concurrent
// operations on one customer queue up instead of spending the same balance twice.
//
// Balances are never written directly: they only move together with a ledger row, and
// only through the wallet processor.
type CustomerWallet struct {
CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
PointBalance int64 `gorm:"not null;default:0" json:"point_balance"`
CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (CustomerWallet) TableName() string {
return "customer_wallets"
}
// WalletTransaction is one ledger row. The ledger is append-only: a correction is a
// new row pointing at the one it corrects, never an update.
type WalletTransaction struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
Currency string `gorm:"not null;size:10" json:"currency"`
Type string `gorm:"not null;size:30" json:"type"`
// Signed: positive credits the wallet, negative debits it.
Amount int64 `gorm:"not null" json:"amount"`
BalanceAfter int64 `gorm:"not null" json:"balance_after"`
GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"`
// Where the value came from (credit) or went to (debit).
ReferenceType string `gorm:"not null;size:30" json:"reference_type"`
ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"`
CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"`
ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"`
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"`
Reason *string `gorm:"size:255" json:"reason"`
// Frozen at creation, so later renames do not rewrite history.
Description string `gorm:"not null;size:255" json:"description"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error {
if t.ID == uuid.Nil {
t.ID = uuid.New()
}
// A nil map would be stored as JSON null rather than an empty object.
if t.Metadata == nil {
t.Metadata = Metadata{}
}
return nil
}
func (WalletTransaction) TableName() string {
return "wallet_transactions"
}
// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from
// the lots that expire soonest. A lot created by a transfer, exchange or refund carries
// the expiry of the lot it came from and points back at it through OriginLotID.
type WalletLot struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
Currency string `gorm:"not null;size:10" json:"currency"`
SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"`
OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"`
OriginalAmount int64 `gorm:"not null" json:"original_amount"`
// A cache of OriginalAmount minus the lot's allocations, and the only wallet column
// that is ever updated.
RemainingAmount int64 `gorm:"not null" json:"remaining_amount"`
// Nil means the lot never expires.
ExpiresAt *time.Time `json:"expires_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (l *WalletLot) BeforeCreate(tx *gorm.DB) error {
if l.ID == uuid.Nil {
l.ID = uuid.New()
}
return nil
}
func (WalletLot) TableName() string {
return "wallet_lots"
}
// WalletLotAllocation records how much a debit ledger row drew from one lot.
type WalletLotAllocation struct {
TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"`
LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"`
Amount int64 `gorm:"not null" json:"amount"`
}
func (WalletLotAllocation) TableName() string {
return "wallet_lot_allocations"
}
+151
View File
@@ -0,0 +1,151 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
// and binding errors are reported without the values sent.
type CustomerPinHandler struct {
pinService service.CustomerPinService
}
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
return &CustomerPinHandler{pinService: pinService}
}
func (h *CustomerPinHandler) Status(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
if !ok {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
}
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
if !ok {
return
}
var req contract.RequestPinOtpRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
}
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
if !ok {
return
}
var req contract.CreateCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
}
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
if !ok {
return
}
var req contract.ChangeCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
}
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
if !ok {
return
}
var req contract.ResetCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
}
// RemovePin is DELETE /marketing/customers/:id/pin.
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
if !ok {
return
}
var req contract.RemoveCustomerPinRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
}
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
if !ok {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
}
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
// values, since those can be PINs.
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
if err := c.ShouldBindJSON(req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
}), method)
return false
}
return true
}
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
raw, _ := c.Get("customer_id")
s, _ := raw.(string)
id, err := uuid.Parse(s)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), method)
return uuid.Nil, false
}
return id, true
}
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
}
// IssuePaymentCode is POST /customer/wallet/payment-code.
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
if !ok {
return
}
var req contract.IssuePaymentCodeRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
}
+56 -3
View File
@@ -1,9 +1,14 @@
package handler
import (
"errors"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
@@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerPoints")
return
}
@@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
@@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWallet")
return
}
@@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
}
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
ctx := c.Request.Context()
customerID, ok := c.Get("customer_id")
customerIDStr, isString := customerID.(string)
if !ok || !isString {
logger.FromContext(ctx).Error("Customer ID not found in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
var query models.ListCustomerWalletTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions")
}
// walletErrorCode tells a bad request apart from a server failure on the wallet
// endpoints.
func walletErrorCode(err error) string {
switch {
case errors.Is(err, processor.ErrInvalidWalletQuery):
return constants.ValidationErrorCode
case errors.Is(err, repository.ErrWalletNotFound):
return constants.NotFoundErrorCode
default:
return constants.InternalServerErrorCode
}
}
+218
View File
@@ -0,0 +1,218 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"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"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
)
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
// gets the right numbers from the new endpoints and from the old /points, /tokens
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, customer, other := uuid.New(), uuid.New(), uuid.New()
customers := []uuid.UUID{customer, other}
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet api test', 'basic')`, org)
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
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 ?`, 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 organization_settings WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
// that the expiry job has not processed.
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
next := time.Now().Year() + 1
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
expired := time.Now().Add(-time.Hour)
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
}); err != nil {
return err
}
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
Description: "Saldo awal dari sistem lama"}}); err != nil {
return err
}
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
Description: "Bayar #ORD-2"}}); err != nil {
return err
}
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
})
return err
}))
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
nil,
)))
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(id uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
}
for _, who := range []struct {
prefix string
id uuid.UUID
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
g := router.Group(who.prefix, as(who.id))
g.GET("/points", h.GetCustomerPoints)
g.GET("/tokens", h.GetCustomerTokens)
g.GET("/wallet", h.GetCustomerWallet)
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
}
get := func(path string) (int, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
return rec.Code, body
}
// The balance endpoints wrap their data in {status, message, data}.
inner := func(body map[string]any) map[string]any {
return body["data"].(map[string]any)["data"].(map[string]any)
}
// Old app: /points and /tokens keep their shape and show the spendable balance.
status, body := get("/a/points")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
assert.Len(t, inner(body)["points_history"], 3)
status, body = get("/a/tokens")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 8, inner(body)["total_tokens"])
// /wallet: new fields, with the old ones still there.
status, body = get("/a/wallet")
require.Equal(t, http.StatusOK, status, body)
w := inner(body)
assert.EqualValues(t, 250, w["point_balance"])
assert.EqualValues(t, 8, w["coin_balance"])
assert.EqualValues(t, 100, w["point_value"])
assert.EqualValues(t, 25000, w["point_discount_value"])
nearest := w["nearest_expiring"].(map[string]any)
// The payment took 50 from the lot expiring first, leaving 50 + 200.
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
assert.Nil(t, nearest["coin"])
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
assert.Contains(t, w, legacy)
}
assert.EqualValues(t, 250, w["total_points"])
recent := w["recent_transactions"].([]any)
require.Len(t, recent, 4)
for _, raw := range recent {
tx := raw.(map[string]any)
assert.NotContains(t, tx, "counterparty_customer_id")
assert.NotContains(t, tx, "metadata")
if tx["amount"].(float64) > 0 {
assert.Contains(t, tx, "source", tx["type"])
assert.NotContains(t, tx, "destination")
} else {
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
assert.NotContains(t, tx, "source")
}
}
// History with filters.
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
require.Equal(t, http.StatusOK, status, body)
page := body["data"].(map[string]any)
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
rows := page["data"].([]any)
require.Len(t, rows, 1)
first := rows[0].(map[string]any)
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
assert.Len(t, first["lots"], 2)
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
status, _ = get("/a/wallet/transactions?currency=GOLD")
assert.Equal(t, http.StatusBadRequest, status)
status, _ = get("/a/wallet/transactions?limit=abc")
assert.Equal(t, http.StatusBadRequest, status)
// A customer who has never had a wallet sees zeros, not an error.
status, body = get("/b/wallet")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 0, inner(body)["point_balance"])
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
status, body = get("/b/wallet/transactions")
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
// A token for a customer that no longer exists.
status, _ = get("/gone/wallet")
assert.Equal(t, http.StatusNotFound, status)
}
func parseTime(t *testing.T, v any) time.Time {
t.Helper()
s, ok := v.(string)
require.True(t, ok, "expected a time string, got %v", v)
parsed, err := time.Parse(time.RFC3339Nano, s)
require.NoError(t, err)
return parsed
}
@@ -0,0 +1,169 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"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/appcontext"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/middleware"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
)
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
// TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
t.Cleanup(func() {
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
})
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
auth := middleware.NewAuthMiddleware(nil)
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
c.Request = c.Request.WithContext(ctx)
}
}
for prefix, who := range map[string]struct {
org uuid.UUID
role string
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
g := router.Group(prefix, as(who.org, who.role))
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
}
call := func(method, path string, body string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
return rec.Code, out
}
path := "/outlets/" + outlet.String() + "/loyalty-settings"
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
// An outlet without settings shows every default and 1% cashback.
status, body := call(http.MethodGet, "/manager"+path, "")
require.Equal(t, http.StatusOK, status, body)
got := data(body)
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
assert.EqualValues(t, 1, got["point_value"])
assert.EqualValues(t, 1, got["point_cashback_percent"])
// A partial update keeps everything it does not mention.
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
require.Equal(t, http.StatusOK, status, body)
got = data(body)
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
assert.Len(t, got["changes"], 3)
// The cashback follows the organization's point value.
orgSettings, err := settings.Organization(context.Background(), org)
require.NoError(t, err)
orgSettings.PointValue = 5
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
require.NoError(t, err)
status, body = call(http.MethodGet, "/manager"+path, "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
// Sending back what GET returned changes nothing.
echo, err := json.Marshal(data(body))
require.NoError(t, err)
status, body = call(http.MethodPut, "/manager"+path, string(echo))
require.Equal(t, http.StatusOK, status, body)
assert.Empty(t, data(body)["changes"])
// null clears a limit.
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
require.Equal(t, http.StatusOK, status, body)
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
for name, bad := range map[string]string{
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
"negative earn_value": `{"coin": {"earn_value": -1}}`,
"negative min_order": `{"point": {"min_order_amount": -5}}`,
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
"unknown field": `{"point": {"earn_per_amout": 50}}`,
"wrong type": `{"point": {"enabled": "yes"}}`,
"not json": `enabled=true`,
} {
status, _ = call(http.MethodPut, "/manager"+path, bad)
assert.Equal(t, http.StatusBadRequest, status, name)
}
status, body = call(http.MethodGet, "/manager"+path, "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
// Purchasing staff can read but not change; another organization sees nothing.
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
assert.Equal(t, http.StatusForbidden, status)
status, _ = call(http.MethodGet, "/other"+path, "")
assert.Equal(t, http.StatusNotFound, status)
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
assert.Equal(t, http.StatusNotFound, status)
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
assert.Equal(t, http.StatusBadRequest, status)
// Every change is in the history with who made it.
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
require.NoError(t, err)
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
for _, c := range history.Data {
assert.Equal(t, admin, c.ChangedBy)
}
}
@@ -0,0 +1,117 @@
package handler
import (
"io"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB.
const loyaltySettingsBodyLimit = 64 << 10
// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2).
type LoyaltySettingsHandler struct {
loyaltySettingsService service.LoyaltySettingsService
}
func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler {
return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService}
}
func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) {
ctx := c.Request.Context()
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings")
if !ok {
return
}
response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings")
}
func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) {
ctx := c.Request.Context()
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings")
if !ok {
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
}), "LoyaltySettingsHandler::UpdateOutletSettings")
return
}
response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings")
}
// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one.
func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param(name))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name),
}), method)
return uuid.Nil, false
}
return id, true
}
func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) {
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings")
}
// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it
// saves nothing and returns what would change and what the balances in circulation
// would be worth, for the warning the dashboard shows before saving (F2).
func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) {
ctx := c.Request.Context()
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
}), "LoyaltySettingsHandler::UpdateOrganizationSettings")
return
}
response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true")
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings")
}
// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet
// (?outlet_id=).
func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) {
ctx := c.Request.Context()
var outletID *uuid.UUID
if raw := c.Query("outlet_id"); raw != "" {
id, err := uuid.Parse(raw)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"),
}), "LoyaltySettingsHandler::ListHistory")
return
}
outletID = &id
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory")
}
@@ -0,0 +1,180 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"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/appcontext"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/middleware"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
)
// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic')`, org)
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
txm := repository.NewTxManager(db)
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
for _, c := range []struct {
currency, ref string
amount int64
}{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} {
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount,
ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal",
Metadata: map[string]interface{}{"frozen": true}}})
return err
}))
}
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
auth := middleware.NewAuthMiddleware(nil)
gin.SetMode(gin.TestMode)
router := gin.New()
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
role := role
g := router.Group(prefix, func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
c.Request = c.Request.WithContext(ctx)
})
g.GET("/loyalty-settings", h.GetOrganizationSettings)
g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings)
g.GET("/loyalty-settings/history", h.ListHistory)
}
call := func(method, path, body string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
return rec.Code, out
}
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
status, body := call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
got := data(body)
assert.EqualValues(t, 1, got["point_value"])
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
impact := got["impact"].(map[string]any)
assert.EqualValues(t, 50000, impact["outstanding_points"])
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
// A dry run shows what the balances in circulation would be worth, and saves nothing.
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change)
require.Equal(t, http.StatusOK, status, body)
got = data(body)
assert.Equal(t, true, got["dry_run"])
impact = got["impact"].(map[string]any)
assert.EqualValues(t, 50000, impact["point_rupiah_before"])
assert.EqualValues(t, 5000000, impact["point_rupiah_after"])
assert.EqualValues(t, 105, impact["coins_as_points_before"])
assert.EqualValues(t, 10, impact["coins_as_points_after"])
assert.Len(t, got["changes"], 2)
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing")
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"])
// Saving records every change with who made it.
status, body = call(http.MethodPut, "/manager/loyalty-settings", change)
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, false, data(body)["dry_run"])
assert.EqualValues(t, 100, data(body)["point_value"])
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
require.Equal(t, http.StatusOK, status, body)
history := data(body)["data"].([]any)
require.Len(t, history, 2)
var keys []string
for _, raw := range history {
row := raw.(map[string]any)
keys = append(keys, row["key"].(string))
assert.Equal(t, admin.String(), row["changed_by"])
assert.Nil(t, row["old_value"], "was on its default")
assert.Nil(t, row["outlet_id"])
}
assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys)
// Nothing already written changes: balances and ledger rows keep their numbers.
var rows []struct {
Amount int64
Metadata string
}
require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error)
require.Len(t, rows, 2)
assert.Equal(t, int64(105), rows[0].Amount)
assert.Equal(t, int64(50000), rows[1].Amount)
for _, r := range rows {
assert.Contains(t, r.Metadata, `"frozen": true`)
}
// Out of bounds, unknown fields and the wrong role are refused.
for name, bad := range map[string]string{
"point value 0": `{"point_value": 0}`,
"exchange 0": `{"exchange": {"coin_amount": 0}}`,
"expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`,
"unknown field": `{"point_valeu": 5}`,
} {
status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad)
assert.Equal(t, http.StatusBadRequest, status, name)
status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad)
assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)")
}
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`)
assert.Equal(t, http.StatusForbidden, status)
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 100, data(body)["point_value"])
}
@@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
req.OrganizationID = &contextInfo.OrganizationID
// At the cashier, EnakPoint is listed only where the outlet accepts it (F9).
if outletStr := c.Query("outlet_id"); outletStr != "" {
if outletID, err := uuid.Parse(outletStr); err == nil {
req.OutletID = &outletID
}
} else if contextInfo.OutletID != uuid.Nil {
outletID := contextInfo.OutletID
req.OutletID = &outletID
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
+1
View File
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
Notes: item.Notes,
})
}
+203
View File
@@ -0,0 +1,203 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"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/appcontext"
"apskel-pos-be/internal/constants"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/validator"
)
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
// TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
orgA, orgB := uuid.New(), uuid.New()
adminA, adminB := uuid.New(), uuid.New()
customer := uuid.New()
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
})
walletRepo := repository.NewWalletRepository(db)
queryRepo := repository.NewWalletQueryRepository(db)
txm := repository.NewTxManager(db)
wallet := processor.NewWalletProcessor(walletRepo)
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
outlet := uuid.New()
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Belanja #ORD-1"}})
return err
}))
h := NewWalletAdminHandler(
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
validator.NewWalletValidator(),
)
gin.SetMode(gin.TestMode)
router := gin.New()
as := func(org, user uuid.UUID) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
c.Request = c.Request.WithContext(ctx)
}
}
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
g := router.Group(prefix, as(who[0], who[1]))
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
}
call := func(method, path string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
require.NoError(t, json.NewEncoder(&buf).Encode(body))
}
req := httptest.NewRequest(method, path, &buf)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
return rec.Code, out
}
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
}
spendable := func() float64 {
t.Helper()
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
require.Equal(t, http.StatusOK, status, body)
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
}
// Add 50.
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
require.Equal(t, http.StatusOK, status, body)
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
assert.Equal(t, "ADJUSTMENT", tx["type"])
assert.EqualValues(t, 50, tx["amount"])
assert.Equal(t, "komplain #45", tx["reason"])
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
// Taking more than the customer can spend is refused and changes nothing.
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
assert.Equal(t, http.StatusBadRequest, status, body)
assert.Equal(t, float64(150), spendable())
// Taking away, retried with the same key, happens once.
for i, wantReplayed := range []bool{false, true} {
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
require.Equal(t, http.StatusOK, status, body)
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
}
assert.Equal(t, float64(120), spendable())
// Bad requests.
for name, req := range map[string]map[string]any{
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
"no reason": {"currency": "POINT", "amount": 10},
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
} {
status, _ = adjust("/a", req)
assert.Equal(t, http.StatusBadRequest, status, name)
}
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
assert.Equal(t, http.StatusBadRequest, status)
assert.Equal(t, float64(120), spendable())
// Another organization's admin cannot see or touch this customer.
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
assert.Equal(t, http.StatusNotFound, status)
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
assert.Equal(t, http.StatusNotFound, status)
assert.Equal(t, float64(120), spendable())
// The dashboard view.
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
require.Equal(t, http.StatusOK, status, body)
w := body["data"].(map[string]any)
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
assert.EqualValues(t, 120, w["point_balance"])
assert.EqualValues(t, 120, w["spendable_point_balance"])
lots := w["lots"].([]any)
var lotTotal float64
for _, l := range lots {
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
}
assert.Equal(t, float64(120), lotTotal)
rows := w["transactions"].(map[string]any)["data"].([]any)
require.Len(t, rows, 2, "the two adjustments, newest first")
newest := rows[0].(map[string]any)
assert.EqualValues(t, -30, newest["amount"])
assert.Equal(t, "salah input", newest["reason"])
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
// The customer's own history shows the adjustment too, with the reason in the
// description and without the admin's identity fields.
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
crouter := gin.New()
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
rec := httptest.NewRecorder()
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var cbody map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
crows := cbody["data"].(map[string]any)["data"].([]any)
require.Len(t, crows, 2)
first := crows[0].(map[string]any)
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
assert.NotContains(t, first, "created_by")
assert.NotContains(t, first, "reason")
}
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
)
// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7).
type WalletAdminHandler struct {
walletAdminService service.WalletAdminService
walletValidator validator.WalletValidator
}
func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler {
return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator}
}
func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
customerID, err := uuid.Parse(c.Param("id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
}), "WalletAdminHandler::GetCustomerWallet")
return
}
var query models.ListCustomerWalletTransactionsQuery
if err := c.ShouldBindQuery(&query); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
}), "WalletAdminHandler::GetCustomerWallet")
return
}
response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet")
}
func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
customerID, err := uuid.Parse(c.Param("id"))
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
var req contract.AdjustCustomerWalletRequest
if err := c.ShouldBindJSON(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.RequestEntity, validationError.Error()),
}), "WalletAdminHandler::AdjustCustomerWallet")
return
}
response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req)
if response.HasErrors() {
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed")
}
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
}
+4 -4
View File
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
return &entities.Campaign{
Name: request.Name,
Description: request.Description,
Type: entities.CampaignType(request.Type),
Type: entities.NormalizeCampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
ID: request.ID,
Name: request.Name,
Description: request.Description,
Type: entities.CampaignType(request.Type),
Type: entities.NormalizeCampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.CampaignRewardType(request.RewardType),
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.CampaignRewardType(request.RewardType),
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
+11
View File
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
}
}
// Unit is only meaningful for weighed lines, but carry it whenever it was captured.
var unitName, unitAbbreviation *string
if item.Unit != nil {
unitName = &item.Unit.Name
unitAbbreviation = item.Unit.Abbreviation
}
response := &models.OrderItemResponse{
ID: item.ID,
OrderID: item.OrderID,
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
UnitID: item.UnitID,
UnitName: unitName,
UnitAbbreviation: unitAbbreviation,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
UnitCost: item.UnitCost,
@@ -134,6 +134,7 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest)
return &models.ListPaymentMethodsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
Type: paymentMethodType,
IsActive: req.IsActive,
Search: req.Search,
+23
View File
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
HasIngredients: entity.HasIngredients,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
BusinessType: string(model.BusinessType),
ImageURL: model.ImageURL,
PrinterType: model.PrinterType,
UnitID: model.UnitID,
SellBy: model.SellBy,
HasIngredients: model.HasIngredients,
Metadata: entities.Metadata(model.Metadata),
IsActive: model.IsActive,
CreatedAt: model.CreatedAt,
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
printerType = *req.PrinterType
}
sellBy := constants.SellByUnit
if constants.IsValidSellBy(req.SellBy) {
sellBy = req.SellBy
}
metadata := entities.Metadata{}
if req.Metadata != nil {
metadata = entities.Metadata(req.Metadata)
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
BusinessType: businessType,
ImageURL: req.ImageURL,
PrinterType: printerType,
UnitID: req.UnitID,
SellBy: sellBy,
Metadata: metadata,
IsActive: true, // Default to active
}
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
PrinterType: entity.PrinterType,
UnitID: entity.UnitID,
SellBy: entity.SellBy,
Metadata: map[string]interface{}(entity.Metadata),
IsActive: entity.IsActive,
CreatedAt: entity.CreatedAt,
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
entity.PrinterType = *req.PrinterType
}
if req.UnitID != nil {
entity.UnitID = req.UnitID
}
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
entity.SellBy = *req.SellBy
}
if req.Metadata != nil {
if entity.Metadata == nil {
entity.Metadata = make(entities.Metadata)
+8
View File
@@ -85,6 +85,14 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only
// admins and managers may do these; unlike RequireAdminOrManager it does not let the
// purchasing role through.
func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
}
+2
View File
@@ -233,6 +233,7 @@ type ProductAnalyticsData struct {
CategoryName string `json:"category_name"`
CategoryOrder int `json:"category_order"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
@@ -359,6 +360,7 @@ type ParentCategoryAnalyticsProductData struct {
ProductSku string `json:"product_sku"`
ProductPrice float64 `json:"product_price"`
QuantitySold int64 `json:"quantity_sold"`
WeightSold float64 `json:"weight_sold"`
Revenue float64 `json:"revenue"`
AveragePrice float64 `json:"average_price"`
OrderCount int64 `json:"order_count"`
+5 -5
View File
@@ -11,7 +11,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"`
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
+47
View File
@@ -0,0 +1,47 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerPinStatus is GET /customer/pin/status.
type CustomerPinStatus struct {
HasPin bool `json:"has_pin"`
LockedUntil *time.Time `json:"locked_until"`
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
}
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
// the code the customer received.
type CustomerPinOtp struct {
Purpose string `json:"purpose"`
OtpToken string `json:"otp_token"`
ExpiresAt time.Time `json:"expires_at"`
}
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
type CustomerSecurityEventView struct {
ID uuid.UUID `json:"id"`
Event string `json:"event"`
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
Reason *string `json:"reason,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
type CustomerPinRequestInfo struct {
IPAddress string
UserAgent string
}
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
// customer shows the cashier, as digits or as a QR of QRPayload.
type PaymentCode struct {
Code string `json:"code"`
QRPayload string `json:"qr_payload"`
ExpiresAt time.Time `json:"expires_at"`
}
+13
View File
@@ -109,7 +109,20 @@ type GetCustomerWalletResponse struct {
Data *GetCustomerWalletResponseData `json:"data,omitempty"`
}
// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6).
type GetCustomerWalletResponseData struct {
// Balances the customer can spend now.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only
// ever pays for orders, so the app shows this as "setara potongan Rp …" (K7).
PointValue int64 `json:"point_value"`
PointDiscountValue int64 `json:"point_discount_value"`
NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"`
RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"`
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
// that still read it. Filled from the wallet like the fields above.
TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
+159
View File
@@ -0,0 +1,159 @@
package models
import (
"math"
"time"
"github.com/google/uuid"
)
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
type OutletLoyaltySettings struct {
Point LoyaltyEarnSettings `json:"point"`
Coin LoyaltyEarnSettings `json:"coin"`
// Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart.
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
}
// LoyaltyEarnSettings is how much of one currency an order earns:
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
// MaxPerOrder when set.
type LoyaltyEarnSettings struct {
Enabled bool `json:"enabled"`
EarnPerAmount int64 `json:"earn_per_amount"`
EarnValue int64 `json:"earn_value"`
MinOrderAmount int64 `json:"min_order_amount"`
MaxPerOrder *int64 `json:"max_per_order"`
}
type LoyaltyPointPaymentSettings struct {
AcceptPayment bool `json:"accept_payment"`
MinPaymentPoints int64 `json:"min_payment_points"`
// Largest share of the order total, 0–100, that EnakPoint may pay.
MaxPaymentPercent int64 `json:"max_payment_percent"`
}
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
// organization (docs/prd-point-coin.md F2, F12).
type OrganizationLoyaltySettings struct {
// Rupiah value of one EnakPoint when paying.
PointValue int64 `json:"point_value"`
// CoinAmount EnakCoin exchange into PointAmount EnakPoint.
Exchange LoyaltyExchangeSettings `json:"exchange"`
Transfer LoyaltyTransferSettings `json:"transfer"`
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
}
type LoyaltyExchangeSettings struct {
CoinAmount int64 `json:"coin_amount"`
PointAmount int64 `json:"point_amount"`
}
type LoyaltyTransferSettings struct {
Enabled bool `json:"enabled"`
MinAmount int64 `json:"min_amount"`
MaxPerTransaction *int64 `json:"max_per_transaction"`
DailyLimit *int64 `json:"daily_limit"`
}
// LoyaltyExpirySettings is how long one currency lasts once received. The expiry
// model is still open (note N4); these are only the stored settings.
type LoyaltyExpirySettings struct {
Enabled bool `json:"enabled"`
Period int64 `json:"period"`
// DAY or MONTH.
Unit string `json:"unit"`
EndOfMonth bool `json:"end_of_month"`
ReminderDays int64 `json:"reminder_days"`
}
// LoyaltySettingChange is one row of the loyalty settings history.
type LoyaltySettingChange struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Key string `json:"key"`
// Nil when the key had no stored value, that is it was on its default.
OldValue *string `json:"old_value"`
NewValue *string `json:"new_value"`
ChangedBy uuid.UUID `json:"changed_by"`
CreatedAt time.Time `json:"created_at"`
}
// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings.
type OutletLoyaltySettingsView struct {
OutletID uuid.UUID `json:"outlet_id"`
OutletLoyaltySettings
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
PointValue int64 `json:"point_value"`
// Effective EnakPoint cashback in percent: earn_value × point_value /
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
// scale (F1).
PointCashbackPercent float64 `json:"point_cashback_percent"`
// Set on PUT: the keys that changed.
Changes []LoyaltySettingChange `json:"changes,omitempty"`
}
// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage,
// rounded to two decimals.
func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 {
if earnPerAmount <= 0 {
return 0
}
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
}
// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings.
type OrganizationLoyaltySettingsView struct {
OrganizationLoyaltySettings
// What the balances in circulation are worth, before and after the change.
Impact LoyaltySettingsImpact `json:"impact"`
// On PUT, the keys that changed; on a dry run, the keys that would.
Changes []LoyaltySettingChange `json:"changes"`
// True when nothing was saved.
DryRun bool `json:"dry_run"`
}
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
// the balances in circulation are worth (F2). Before and after are equal when neither
// changes.
type LoyaltySettingsImpact struct {
OutstandingPoints int64 `json:"outstanding_points"`
OutstandingCoins int64 `json:"outstanding_coins"`
PointValueBefore int64 `json:"point_value_before"`
PointValueAfter int64 `json:"point_value_after"`
PointRupiahBefore int64 `json:"point_rupiah_before"`
PointRupiahAfter int64 `json:"point_rupiah_after"`
// The coins in circulation exchanged at the rate, in EnakPoint and in rupiah.
CoinsAsPointsBefore int64 `json:"coins_as_points_before"`
CoinsAsPointsAfter int64 `json:"coins_as_points_after"`
CoinRupiahBefore int64 `json:"coin_rupiah_before"`
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
}
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
// to another on the balances in circulation.
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
asPoints := func(s OrganizationLoyaltySettings) int64 {
if s.Exchange.CoinAmount <= 0 {
return 0
}
return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount
}
impact := LoyaltySettingsImpact{
OutstandingPoints: points,
OutstandingCoins: coins,
PointValueBefore: before.PointValue,
PointValueAfter: after.PointValue,
PointRupiahBefore: points * before.PointValue,
PointRupiahAfter: points * after.PointValue,
CoinsAsPointsBefore: asPoints(before),
CoinsAsPointsAfter: asPoints(after),
}
impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue
impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue
return impact
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoyaltyCashbackPercent(t *testing.T) {
assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100))
assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000))
assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals")
assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut")
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
}
func TestNewLoyaltySettingsImpact(t *testing.T) {
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
got := NewLoyaltySettingsImpact(50000, 105, before, after)
assert.Equal(t, LoyaltySettingsImpact{
OutstandingPoints: 50000, OutstandingCoins: 105,
PointValueBefore: 1, PointValueAfter: 100,
PointRupiahBefore: 50000, PointRupiahAfter: 5000000,
CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10)
CoinRupiahBefore: 105, CoinRupiahAfter: 1000,
}, got)
same := NewLoyaltySettingsImpact(10, 10, before, before)
assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter)
}
+11 -1
View File
@@ -44,6 +44,8 @@ type OrderItem struct {
ProductID uuid.UUID
ProductVariantID *uuid.UUID
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitPrice float64
TotalPrice float64
UnitCost float64
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
ProductID uuid.UUID `validate:"required"`
ProductVariantID *uuid.UUID `validate:"omitempty"`
Quantity int `validate:"required,min=1"`
UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
Weight *float64 `validate:"omitempty,gt=0"`
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
Modifiers []map[string]interface{} `validate:"omitempty"`
Notes *string `validate:"omitempty,max=500"`
Metadata map[string]interface{} `validate:"omitempty"`
@@ -179,6 +182,9 @@ type OrderResponse struct {
TotalPaid float64
PaymentCount int
SplitType *string
// What the order earned (docs/prd-point-coin.md F3), from its EARN rows.
PointsEarned int64
CoinsEarned int64
}
type OrderItemResponse struct {
@@ -191,6 +197,10 @@ type OrderItemResponse struct {
CategoryID *uuid.UUID
CategoryName *string
Quantity int
Weight *float64
UnitID *uuid.UUID
UnitName *string
UnitAbbreviation *string
UnitPrice float64
TotalPrice float64
UnitCost float64
+7 -5
View File
@@ -51,11 +51,13 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
// When set, EnakPoint is left out unless the outlet accepts it (F9).
OutletID *uuid.UUID
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
}
type ListPaymentMethodsResponse struct {
+4
View File
@@ -19,6 +19,7 @@ type Product struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
UnitID *uuid.UUID
HasIngredients bool
Metadata map[string]interface{}
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy string `validate:"omitempty,oneof=unit weight"`
HasIngredients bool `validate:"omitempty"`
Metadata map[string]interface{}
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
PrinterType *string `validate:"omitempty,max=50"`
PrintToChecker *bool `validate:"omitempty"`
UnitID *uuid.UUID `validate:"omitempty"`
SellBy *string `validate:"omitempty,oneof=unit weight"`
HasIngredients *bool `validate:"omitempty"`
Metadata map[string]interface{}
IsActive *bool
@@ -110,6 +113,7 @@ type ProductResponse struct {
BusinessType constants.BusinessType
ImageURL *string
PrinterType string
SellBy string
PrintToChecker bool
UnitID *uuid.UUID
HasIngredients bool
+140
View File
@@ -0,0 +1,140 @@
package models
import (
"time"
"github.com/google/uuid"
)
// CustomerWalletTransaction is one ledger row as the customer app shows it
// (docs/prd-point-coin.md F6).
type CustomerWalletTransaction struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
Type string `json:"type"`
// Signed: positive added to the balance, negative taken from it.
Amount int64 `json:"amount"`
BalanceAfter int64 `json:"balance_after"`
Description string `json:"description"`
// Where the value came from, set on additions.
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
// Where the value went, set on deductions.
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
// Shared by the two rows of an exchange or a transfer.
GroupID *uuid.UUID `json:"group_id,omitempty"`
// Additions only: the earliest expiry among the lots it created, nil when none of
// them expire, and the lots themselves.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Lots []CustomerWalletLot `json:"lots,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
type CustomerWalletTransactionRef struct {
Type string `json:"type"`
ID uuid.UUID `json:"id"`
}
type CustomerWalletLot struct {
Amount int64 `json:"amount"`
Remaining int64 `json:"remaining"`
ExpiresAt *time.Time `json:"expires_at"`
}
// CustomerWalletExpiring is how much expires on one day.
type CustomerWalletExpiring struct {
Amount int64 `json:"amount"`
// YYYY-MM-DD, Asia/Jakarta.
Date string `json:"date"`
}
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
// nothing is due to expire.
type CustomerWalletNearestExpiring struct {
Point *CustomerWalletExpiring `json:"point"`
Coin *CustomerWalletExpiring `json:"coin"`
}
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
type ListCustomerWalletTransactionsQuery struct {
Page int `form:"page"`
Limit int `form:"limit"`
Currency string `form:"currency"`
// One type, or several separated by commas.
Type string `form:"type"`
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
From string `form:"from"`
To string `form:"to"`
}
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
// ones, every lot that still holds something, and the real names behind each row.
type AdminCustomerWallet struct {
Customer AdminWalletCustomer `json:"customer"`
// Balances as the ledger has them.
PointBalance int64 `json:"point_balance"`
CoinBalance int64 `json:"coin_balance"`
// What can be spent now. Lower than the ledger balance only while lots that have
// expired wait for the expiry job.
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
Lots []AdminWalletLot `json:"lots"`
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
}
type AdminWalletCustomer struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Phone *string `json:"phone,omitempty"`
}
type AdminWalletLot struct {
ID uuid.UUID `json:"id"`
Currency string `json:"currency"`
OriginalAmount int64 `json:"original_amount"`
RemainingAmount int64 `json:"remaining_amount"`
ExpiresAt *time.Time `json:"expires_at"`
Expired bool `json:"expired"`
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// AdminWalletTransaction is a ledger row with the names the customer does not see:
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
// took a payment, and the outlet.
type AdminWalletTransaction struct {
CustomerWalletTransaction
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
Reason *string `json:"reason,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type AdminWalletNamedRef struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
// WalletAdjustment is a manual correction by an admin.
type WalletAdjustment struct {
Currency string
// Signed: positive adds, negative takes away.
Amount int64
Reason string
IdempotencyKey string
}
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
type AdminWalletAdjustmentResult struct {
Transaction AdminWalletTransaction `json:"transaction"`
SpendablePointBalance int64 `json:"spendable_point_balance"`
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
// True when the idempotency key had been used before and nothing changed.
Replayed bool `json:"replayed"`
}
@@ -322,6 +322,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
CategoryName: data.CategoryName,
CategoryOrder: data.CategoryOrder,
QuantitySold: data.QuantitySold,
WeightSold: data.WeightSold,
Revenue: data.Revenue,
AveragePrice: data.AveragePrice,
OrderCount: data.OrderCount,
@@ -447,6 +448,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
ProductSku: product.ProductSku,
ProductPrice: product.ProductPrice,
QuantitySold: product.QuantitySold,
WeightSold: product.WeightSold,
Revenue: product.Revenue,
AveragePrice: product.AveragePrice,
OrderCount: product.OrderCount,
+4 -4
View File
@@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis
Page: req.Page,
Limit: req.Limit,
Search: req.Search,
Type: req.Type,
Type: string(entities.NormalizeCampaignType(req.Type)),
IsActive: req.IsActive,
ShowOnApp: req.ShowOnApp,
StartDate: req.StartDate,
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue,
RewardType: entities.CampaignRewardType(req.RewardType),
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID,
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
Limit: req.Limit,
CampaignID: req.CampaignID,
RuleType: req.RuleType,
RewardType: req.RewardType,
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)),
}
// Get from repository
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue,
RewardType: entities.CampaignRewardType(req.RewardType),
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID,
@@ -0,0 +1,467 @@
package processor
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
const (
pinLength = 6
pinMaxAttempts = 5
pinLockDuration = 30 * time.Minute
pinTransferHold = 24 * time.Hour
pinSecurityReasonN = 255
PinOtpPurposeSetup = "pin_setup"
PinOtpPurposeReset = "pin_reset"
)
// Security log events.
const (
PinEventSet = "PIN_SET"
PinEventChanged = "PIN_CHANGED"
PinEventReset = "PIN_RESET"
PinEventFailed = "PIN_FAILED"
PinEventLocked = "PIN_LOCKED"
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
)
// What a PIN approves. Only a transfer is held after a reset.
type PinAction string
const (
PinActionPay PinAction = "PAY"
PinActionExchange PinAction = "EXCHANGE"
PinActionTransfer PinAction = "TRANSFER"
)
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
const (
PinErrNotSet = "PIN_NOT_SET"
PinErrInvalid = "PIN_INVALID"
PinErrLocked = "PIN_LOCKED"
PinErrTransferBlocked = "TRANSFER_BLOCKED"
)
// PinError is why a PIN did not approve an action.
type PinError struct {
Code string
// Set for PIN_INVALID: attempts left before the PIN locks.
RemainingAttempts int
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
Until *time.Time
}
func (e *PinError) Error() string {
switch e.Code {
case PinErrNotSet:
return "PIN has not been set"
case PinErrInvalid:
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
case PinErrLocked:
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
case PinErrTransferBlocked:
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
}
return e.Code
}
var (
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
// message never contains the PIN.
ErrInvalidPinInput = errors.New("invalid PIN")
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
ErrPinAlreadySet = errors.New("PIN has already been set")
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
// sent to another number.
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
ErrPinNoPhone = errors.New("customer has no phone number")
)
type pinOtpSender interface {
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
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
}
// 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
}
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 (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
now := p.now()
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
if state.LockedUntil != nil && state.LockedUntil.After(now) {
status.LockedUntil = state.LockedUntil
}
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
status.TransferBlockedUntil = state.TransferBlockedUntil
}
return status, nil
}
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
// (pin_setup) or resetting a forgotten one (pin_reset).
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
switch purpose {
case PinOtpPurposeSetup:
if state.PinHash != nil {
return nil, ErrPinAlreadySet
}
case PinOtpPurposeReset:
if state.PinHash == nil {
return nil, &PinError{Code: PinErrNotSet}
}
default:
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
}
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
return nil, ErrPinNoPhone
}
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if !canSend {
return nil, ErrPinOtpTooSoon
}
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
if err != nil {
return nil, err
}
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
return nil, err
}
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
}
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
// by the owner of the number and not by whoever holds a logged-in phone.
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash != nil {
return ErrPinAlreadySet
}
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
return nil
}
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
// like any other attempt. A transfer hold from an earlier reset stays.
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
return nil
}
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
// taken over (Q16).
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
return err
}
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
return err
}
hash, err := p.hash(pin)
if err != nil {
return err
}
hold := p.now().Add(pinTransferHold)
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
return err
}
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
return nil
}
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
return p.verify(ctx, state, pin, action, info)
}
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
now := p.now()
// A locked PIN is refused before it is compared, even when it is right.
if state.LockedUntil != nil && state.LockedUntil.After(now) {
until := *state.LockedUntil
return &PinError{Code: PinErrLocked, Until: &until}
}
// A held transfer is refused before the PIN is compared, so it costs no attempt.
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
until := *state.TransferBlockedUntil
return &PinError{Code: PinErrTransferBlocked, Until: &until}
}
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
if err != nil {
return err
}
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
if lockedUntil != nil && lockedUntil.After(now) {
// Only the attempt that reached the limit logs the lock and tells the
// customer; attempts racing it just see the lock.
if attempts == pinMaxAttempts {
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
p.alertLocked(state, *lockedUntil)
}
return &PinError{Code: PinErrLocked, Until: lockedUntil}
}
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
}
if state.FailedAttempts > 0 || state.LockedUntil != nil {
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
return err
}
}
return nil
}
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
// so they have to create a new one through OTP. Admins can never set or read a PIN.
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
reason = strings.TrimSpace(reason)
if reason == "" {
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
}
if adminID == uuid.Nil {
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
}
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return err
}
if state.OrganizationID != organizationID {
return repository.ErrPinCustomerNotFound
}
if state.PinHash == nil {
return &PinError{Code: PinErrNotSet}
}
if err := p.repo.RemovePin(ctx, customerID); err != nil {
return err
}
reason = truncateRunes(reason, pinSecurityReasonN)
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
return nil
}
// ListEvents returns a page of a customer's PIN security log for the dashboard.
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
state, err := p.repo.GetState(ctx, customerID)
if err != nil {
return nil, err
}
if state.OrganizationID != organizationID {
return nil, repository.ErrPinCustomerNotFound
}
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
if err != nil {
return nil, err
}
events := make([]models.CustomerSecurityEventView, 0, len(rows))
for _, e := range rows {
events = append(events, models.CustomerSecurityEventView{
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
})
}
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
Data: events,
Pagination: models.Pagination{
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
},
}, nil
}
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
// own phone number. Without those checks an OTP from the login flow, or one sent to
// another number, could approve a PIN change.
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
if token == "" || code == "" || state.PhoneNumber == nil {
return ErrPinOtpInvalid
}
session, err := p.otp.ValidateOtpSession(ctx, token, code)
if err != nil || session == nil {
return ErrPinOtpInvalid
}
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
return ErrPinOtpInvalid
}
return nil
}
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
if err != nil {
return "", fmt.Errorf("failed to hash PIN: %w", err)
}
return string(hash), nil
}
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
return state.TransferBlockedUntil
}
return nil
}
// logEvent records a security event. The log is best effort: failing to write it must
// not undo what the customer just did, so a failure is logged instead.
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
if info.IPAddress != "" {
ip := truncateRunes(info.IPAddress, 45)
e.IPAddress = &ip
}
if info.UserAgent != "" {
ua := truncateRunes(info.UserAgent, 255)
e.UserAgent = &ua
}
if err := p.repo.InsertEvent(ctx, e); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
}
}
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
if p.alerter == nil || state.PhoneNumber == 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.",
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
}
}
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
// or YYMMDD.
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
if len(pin) != pinLength {
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
}
for _, r := range pin {
if r < '0' || r > '9' {
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
}
}
if pin != confirm {
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
}
same, up, down := true, true, true
for i := 1; i < len(pin); i++ {
d := int(pin[i]) - int(pin[i-1])
same = same && d == 0
up = up && d == 1
down = down && d == -1
}
if same || up || down {
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
}
if birthDate != nil {
for _, layout := range []string{"020106", "060102"} {
if pin == birthDate.Format(layout) {
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
}
}
}
return nil
}
@@ -0,0 +1,257 @@
package processor
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// otpFake keeps OTP sessions in memory with the checks the real one makes.
type otpFake struct {
mu sync.Mutex
sessions map[string]*entities.OtpSession
sent []string
}
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
return true, 0, nil
}
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
f.sessions[s.Token] = s
return s, nil
}
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
f.sent = append(f.sent, purpose)
return nil
}
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
s := f.sessions[token]
if s == nil || s.IsUsed || s.Code != code {
return nil, errors.New("invalid OTP")
}
s.IsUsed = true
return s, nil
}
// issue creates a session as if it had been sent, for any purpose and number.
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
return s
}
type alerterFake struct {
mu sync.Mutex
messages []string
}
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.messages = append(f.messages, message)
return nil
}
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestCustomerPin_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
phone := "0812" + customer.String()[:8]
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
t.Cleanup(func() {
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
})
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
alerts := &alerterFake{}
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
p.cost = bcrypt.MinCost
clock := time.Now()
var clockMu sync.Mutex
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
const pin, newPin, resetPin = "482913", "572039", "613408"
pinErr := func(err error) *PinError {
t.Helper()
var pe *PinError
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
for _, secret := range []string{pin, newPin, resetPin} {
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
}
return pe
}
events := func() []string {
t.Helper()
var out []string
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
return out
}
// No PIN yet: nothing can be approved.
status, err := p.Status(ctx, customer)
require.NoError(t, err)
assert.False(t, status.HasPin)
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
// Creating the first PIN takes an OTP sent to the customer's own number, for this
// purpose.
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
require.NoError(t, err)
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
loginOtp := otp.issue(phone, "login")
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
var stored string
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
assert.NotContains(t, stored, pin, "only a hash is stored")
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
// Four wrong attempts count down; the fifth locks for 30 minutes.
for left := 4; left >= 1; left-- {
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, left, pe.RemainingAttempts)
}
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
// While locked even the right PIN is refused.
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.NotNil(t, status.LockedUntil)
// Once the lock runs out a wrong PIN starts a new series of five.
advance(31 * time.Minute)
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, 4, pe.RemainingAttempts)
// The right PIN resets the count.
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
assert.Equal(t, 4, pe.RemainingAttempts)
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
// Wrong attempts made at once all count: none slips past the lock.
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
}
wg.Wait()
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
assert.Equal(t, PinErrLocked, pe.Code)
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
require.NoError(t, err)
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
resetOtp := otp.issue(phone, PinOtpPurposeReset)
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.Nil(t, status.LockedUntil, "the lock is lifted")
require.NotNil(t, status.TransferBlockedUntil)
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
assert.Equal(t, PinErrTransferBlocked, pe.Code)
var failed int
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
assert.Zero(t, failed, "a held transfer costs no attempt")
// Changing the PIN needs the old one and keeps the transfer hold.
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
advance(25 * time.Hour)
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
// An admin can remove the PIN, only in their own organization and with a reason.
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
status, err = p.Status(ctx, customer)
require.NoError(t, err)
assert.False(t, status.HasPin)
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
// Every event is in the security log, with where it came from.
got := events()
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
assert.Contains(t, got, want)
}
page, err := p.ListEvents(ctx, org, customer, 1, 100)
require.NoError(t, err)
assert.EqualValues(t, len(got), page.Pagination.Total)
removed := page.Data[0]
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
assert.Equal(t, &admin, removed.ActorUser)
assert.Equal(t, "hilang HP", *removed.Reason)
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
var locked int
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
}
@@ -0,0 +1,36 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestCheckNewPin(t *testing.T) {
birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC)
for _, ok := range []string{"482913", "019283", "135790", "112233"} {
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"},
} {
err := checkNewPin(c[0], c[1], &birth)
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name)
}
// Without a birth date only the other rules apply.
assert.NoError(t, checkNewPin("140390", "140390", nil))
}
+40 -126
View File
@@ -3,7 +3,6 @@ package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
@@ -12,14 +11,14 @@ import (
)
type CustomerPointsProcessor struct {
customerPointsRepo repository.CustomerPointsRepository
gameRepo *repository.GameRepository
walletQuery *WalletQueryProcessor
gameRepo *repository.GameRepository
}
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
return &CustomerPointsProcessor{
customerPointsRepo: customerPointsRepo,
gameRepo: gameRepo,
walletQuery: walletQuery,
gameRepo: gameRepo,
}
}
@@ -70,159 +69,74 @@ func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID u
return nil, fmt.Errorf("not implemented")
}
// The three endpoints below are the customer app's balance screens. They read the
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
// for balances (docs/prd-point-coin.md §9).
func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) {
// Get total points
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer total points: %w", err)
return nil, err
}
// Get points history (last 10 records)
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10)
data, err := p.walletQuery.LegacyPoints(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get customer points history: %w", err)
return nil, fmt.Errorf("failed to get customer points: %w", err)
}
// Convert to response format
var historyItems []models.PointsHistoryItem
for _, point := range pointsHistory {
historyItems = append(historyItems, models.PointsHistoryItem{
ID: point.ID.String(),
Points: point.Balance,
Type: "BALANCE",
Description: "Points balance",
CreatedAt: point.CreatedAt,
})
}
var lastUpdated time.Time
if len(pointsHistory) > 0 {
lastUpdated = pointsHistory[0].CreatedAt
}
return &models.GetCustomerPointsResponse{
Status: "SUCCESS",
Message: "Customer points retrieved successfully.",
Data: &models.GetCustomerPointsResponseData{
TotalPoints: totalPoints,
PointsHistory: historyItems,
LastUpdated: lastUpdated,
},
Data: data,
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
// Get total tokens
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
return nil, err
}
// Get tokens history (last 10 records)
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10)
data, err := p.walletQuery.LegacyTokens(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
// Convert to response format
var historyItems []models.TokensHistoryItem
for _, token := range tokensHistory {
historyItems = append(historyItems, models.TokensHistoryItem{
ID: token.ID.String(),
Tokens: token.Balance,
Type: string(token.TokenType),
Description: "Tokens balance",
CreatedAt: token.CreatedAt,
})
}
var lastUpdated time.Time
if len(tokensHistory) > 0 {
lastUpdated = tokensHistory[0].CreatedAt
}
return &models.GetCustomerTokensResponse{
Status: "SUCCESS",
Message: "Customer tokens retrieved successfully.",
Data: &models.GetCustomerTokensResponseData{
TotalTokens: totalTokens,
TokensHistory: historyItems,
LastUpdated: lastUpdated,
},
Data: data,
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
// Get total points
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer total points: %w", err)
return nil, err
}
// Get total tokens
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
data, err := p.walletQuery.Summary(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
}
// Get points history (last 5 records)
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5)
if err != nil {
return nil, fmt.Errorf("failed to get customer points history: %w", err)
}
// Get tokens history (last 5 records)
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
}
// Convert to response format
var pointsHistoryItems []models.PointsHistoryItem
var tokensHistoryItems []models.TokensHistoryItem
var lastUpdated time.Time
for _, point := range pointsHistory {
pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{
ID: point.ID.String(),
Points: point.Balance,
Type: "BALANCE",
Description: "Points balance",
CreatedAt: point.CreatedAt,
})
if point.CreatedAt.After(lastUpdated) {
lastUpdated = point.CreatedAt
}
}
for _, token := range tokensHistory {
tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{
ID: token.ID.String(),
Tokens: token.Balance,
Type: string(token.TokenType),
Description: "Tokens balance",
CreatedAt: token.CreatedAt,
})
if token.CreatedAt.After(lastUpdated) {
lastUpdated = token.CreatedAt
}
}
return &models.GetCustomerWalletResponse{
Status: "SUCCESS",
Message: "Customer wallet retrieved successfully.",
Data: &models.GetCustomerWalletResponseData{
TotalPoints: totalPoints,
TotalTokens: totalTokens,
PointsHistory: pointsHistoryItems,
TokensHistory: tokensHistoryItems,
LastUpdated: lastUpdated,
},
Data: data,
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, err
}
return p.walletQuery.Transactions(ctx, id, query)
}
func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
id, err := uuid.Parse(customerID)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery)
}
return id, nil
}
// GetCustomerGamesAPI gets active SPIN games for customers
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
// Get active SPIN games
+85
View File
@@ -0,0 +1,85 @@
package processor
import (
"math"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// EarningLine is what an order earns in one currency, and the settings that produced
// it. The settings are kept so the ledger row can freeze them: a later change of
// setting must not change what an earning meant, and a reversal is computed with the
// same numbers (docs/prd-point-coin.md F3).
type EarningLine struct {
Amount int64
Settings models.LoyaltyEarnSettings
// True when max_per_order cut the amount down.
Capped bool
}
// EarningResult is what an order earns.
type EarningResult struct {
// subtotal − discount − the part paid with EnakPoint, in rupiah, never negative.
// Tax and anything else added on top of the subtotal are not part of it (Q1).
Basis float64
Point EarningLine
Coin EarningLine
}
// Metadata is the snapshot stored on the currency's EARN ledger row.
func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
m := entities.Metadata{
"basis": r.Basis,
"earn_per_amount": line.Settings.EarnPerAmount,
"earn_value": line.Settings.EarnValue,
"min_order_amount": line.Settings.MinOrderAmount,
"capped": line.Capped,
}
if line.Settings.MaxPerOrder != nil {
m["max_per_order"] = *line.Settings.MaxPerOrder
}
return m
}
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
//
// basis = subtotal − discount_amount − paid with EnakPoint
// amount = 0 if basis < min_order_amount
// amount = floor(basis / earn_per_amount) × earn_value
// amount = min(amount, max_per_order) if max_per_order is set
//
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
// floor never lands one short on a value like 87500.00 that float64 cannot hold
// exactly. It has no side effects.
func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult {
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount)
if basisCents < 0 {
basisCents = 0
}
return EarningResult{
Basis: float64(basisCents) / 100,
Point: earnLine(basisCents, settings.Point),
Coin: earnLine(basisCents, settings.Coin),
}
}
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
line := EarningLine{Settings: s}
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
return line
}
if basisCents < s.MinOrderAmount*100 {
return line
}
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
line.Amount = *s.MaxPerOrder
line.Capped = true
}
return line
}
func toCents(rupiah float64) int64 {
return int64(math.Round(rupiah * 100))
}
@@ -0,0 +1,121 @@
package processor
import (
"testing"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example.
func prdEarningSettings() models.OutletLoyaltySettings {
return models.OutletLoyaltySettings{
Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1},
Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1},
}
}
func TestCalculateEarning_PRDExample(t *testing.T) {
// Subtotal after discount Rp 87.500, paid in full in cash.
order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125}
got := CalculateEarning(order, 0, prdEarningSettings())
assert.Equal(t, 87500.0, got.Basis)
assert.Equal(t, int64(875), got.Point.Amount)
assert.Equal(t, int64(3), got.Coin.Amount)
// Rp 20.000 of it paid with EnakPoint earns nothing.
got = CalculateEarning(order, 20000, prdEarningSettings())
assert.Equal(t, 67500.0, got.Basis)
assert.Equal(t, int64(675), got.Point.Amount)
assert.Equal(t, int64(2), got.Coin.Amount)
}
func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) {
withoutTax := &entities.Order{Subtotal: 50000}
withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500}
assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings()))
assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount)
}
func TestCalculateEarning_BelowMinimum(t *testing.T) {
s := prdEarningSettings()
s.Point.MinOrderAmount = 50000
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns")
// The minimum applies to the basis, after discount and EnakPoint.
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount)
// Coin has its own minimum.
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount)
}
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
s := prdEarningSettings()
max := int64(300)
s.Point.MaxPerOrder = &max
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
assert.Equal(t, int64(300), got.Point.Amount)
assert.True(t, got.Point.Capped)
assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"])
got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s)
assert.Equal(t, int64(200), got.Point.Amount)
assert.False(t, got.Point.Capped)
zero := int64(0)
s.Point.MaxPerOrder = &zero
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
}
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
s := prdEarningSettings()
s.Point.Enabled = false
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
assert.Equal(t, int64(0), got.Point.Amount)
assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own")
s.Coin.Enabled = false
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
assert.Equal(t, int64(0), got.Coin.Amount)
assert.Equal(t, 87500.0, got.Basis, "the basis is still reported")
// The defaults of an outlet that never set anything earn nothing.
var defaults models.OutletLoyaltySettings
loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test")
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults)
assert.Equal(t, int64(0), got.Point.Amount)
assert.Equal(t, int64(0), got.Coin.Amount)
}
func TestCalculateEarning_EdgeCases(t *testing.T) {
s := prdEarningSettings()
// floor, not round.
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
// Values float64 cannot hold exactly do not lose a point: computed in float64 this
// basis divides to 4956.999…, which a naive floor turns into 4956.
per250 := prdEarningSettings()
per250.Point.EarnPerAmount = 250
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount)
// Paying more with EnakPoint than the basis leaves nothing, never a negative amount.
got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s)
assert.Equal(t, 0.0, got.Basis)
assert.Equal(t, int64(0), got.Point.Amount)
// earn_value multiplies.
s.Point.EarnValue = 5
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
// A zero earn_value earns nothing even when enabled.
s.Point.EarnValue = 0
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
}
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
assert.Equal(t, entities.Metadata{
"basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false,
}, got.Metadata(got.Point))
}
+198
View File
@@ -0,0 +1,198 @@
package processor
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// Why an order earned nothing.
const (
EarningSkipNotPaid = "NOT_PAID"
EarningSkipVoid = "VOID"
EarningSkipNoCustomer = "NO_CUSTOMER"
EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER"
EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER"
EarningSkipNothingToEarn = "NOTHING_TO_EARN"
)
// EarningOutcome is what earning did for one order.
type EarningOutcome struct {
Points int64
Coins int64
// Set when the order earned nothing, to say why.
Skipped string
}
type outletSettingsReader interface {
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
}
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
// (docs/prd-point-coin.md F3).
type EarningProcessor struct {
orders repository.EarningRepository
settings outletSettingsReader
wallet *WalletProcessor
tx TxRunner
}
func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx}
}
// OnOrderPaid is called once an order has become fully paid and the payment has
// committed. It never fails the caller: a failed earning is logged and picked up later
// by EarnMissing, and the idempotency keys make that retry safe.
func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
defer func() {
if r := recover(); r != nil {
logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r))
}
}()
if _, err := p.EarnForOrder(ctx, orderID); err != nil {
logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err)
}
}
// EarnForOrder credits what a paid order earns. Calling it again for the same order
// credits nothing more.
func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) {
order, err := p.orders.GetOrderForEarning(ctx, orderID)
if err != nil {
return nil, err
}
if skip := earningSkipReason(order); skip != "" {
return &EarningOutcome{Skipped: skip}, nil
}
settings, err := p.settings.Outlet(ctx, order.OutletID)
if err != nil {
return nil, err
}
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
if err != nil {
return nil, err
}
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings)
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
}
outcome := &EarningOutcome{}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
for _, c := range []struct {
currency string
line EarningLine
total *int64
}{
{constants.WalletCurrencyPoint, result.Point, &outcome.Points},
{constants.WalletCurrencyCoin, result.Coin, &outcome.Coins},
} {
if c.line.Amount == 0 {
continue
}
outletID := order.OutletID
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: *order.CustomerID,
Currency: c.currency,
Type: constants.WalletTxTypeEarn,
Amount: c.line.Amount,
ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: order.ID,
OutletID: &outletID,
Description: earningDescription(order),
Metadata: result.Metadata(c.line),
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
// Lots never expire until the expiry model is decided (F12, note N4).
}})
if err != nil {
return fmt.Errorf("crediting %s: %w", c.currency, err)
}
*c.total = res.Transaction.Amount
}
return nil
})
if err != nil {
return nil, err
}
return outcome, nil
}
// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the
// given time that should have earned and have no EARN row, and earns for them. It
// returns how many orders it looked at and how many now earned. One order failing does
// not stop the others.
func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) {
const page = 200
var after *repository.EarningCursor
for checked < maxOrders {
batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page)
if err != nil {
return checked, earned, err
}
if len(batch) == 0 {
break
}
for _, candidate := range batch {
checked++
outcome, err := p.EarnForOrder(ctx, candidate.ID)
if err != nil {
logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err)
continue
}
if outcome.Skipped == "" {
earned++
}
}
last := batch[len(batch)-1]
after = &last
}
return checked, earned, nil
}
func earningSkipReason(order *repository.EarningOrder) string {
switch {
case order.PaymentStatus != string(entities.PaymentStatusCompleted):
return EarningSkipNotPaid
case order.IsVoid:
return EarningSkipVoid
case order.CustomerID == nil || order.CustomerIsDefault == nil:
return EarningSkipNoCustomer
case *order.CustomerIsDefault:
return EarningSkipDefaultCustomer
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
return EarningSkipInactiveCustomer
}
return ""
}
func earningDescription(order *repository.EarningOrder) string {
description := "Belanja #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
return truncateRunes(description, walletDescriptionLimit)
}
// EarnedByOrders returns what each order earned, as written in its EARN rows, for the
// order screen and the receipt (F3). Orders that earned nothing are absent.
func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
rows, err := p.orders.EarnedByOrders(ctx, orderIDs)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]OrderEarned, len(rows))
for id, byCurrency := range rows {
out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]}
}
return out, nil
}
@@ -0,0 +1,229 @@
package processor
import (
"context"
"errors"
"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/models"
"apskel-pos-be/internal/repository"
)
// failingSettings fails for the outlet settings until healed, to stand in for the
// database being unreachable right after a payment.
type failingSettings struct {
real outletSettingsReader
mu sync.Mutex
fail bool
}
func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
f.mu.Lock()
fail := f.fail
f.mu.Unlock()
if fail {
return nil, errors.New("connection refused")
}
return f.real.Outlet(ctx, outletID)
}
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestEarningProcessor_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org, user := uuid.New(), uuid.New()
earningOutlet, quietOutlet := uuid.New(), uuid.New()
regular, inactive := uuid.New(), uuid.New()
var walkIn uuid.UUID
var customers []uuid.UUID
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'earning test', 'basic')`, org)
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org)
exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES
(?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org)
// Creating the organization created its walk-in customer (trigger_create_default_customer).
var walkInID string
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error)
walkIn = uuid.MustParse(walkInID)
customers = []uuid.UUID{regular, walkIn, inactive}
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 ?`, 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 orders WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
db.Exec(`DELETE FROM users WHERE id = ?`, user)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
txm := repository.NewTxManager(db)
settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet)
require.NoError(t, err)
outletSettings.Point.Enabled = true
outletSettings.Coin.Enabled = true
_, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings)
require.NoError(t, err)
settings := &failingSettings{real: settingsProcessor}
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm)
orderNo := 0
newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID {
t.Helper()
orderNo++
id := uuid.New()
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void)
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`,
id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid)
return id
}
earnRows := func(orderID uuid.UUID) map[string]int64 {
t.Helper()
var rows []struct {
Currency string
Amount int64
}
require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error)
out := map[string]int64{}
for _, r := range rows {
out[r.Currency] += r.Amount
}
return out
}
// Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin.
paid := newOrder(earningOutlet, &regular, "completed", false, 97500, 10000)
earning.OnOrderPaid(ctx, paid)
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
var row struct {
Description string
Metadata string
OutletID string
}
require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions
WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error)
assert.Contains(t, row.Description, "di Kemang")
assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row")
assert.Contains(t, row.Metadata, `"basis": 87500`)
assert.Equal(t, earningOutlet.String(), row.OutletID)
// Called again, and five times at once, it is still one earning.
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }()
}
wg.Wait()
outcome, err := earning.EarnForOrder(ctx, paid)
require.NoError(t, err)
assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning")
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
// A self-order goes through the same payment path and the same rules.
selfOrder := newOrder(earningOutlet, &regular, "completed", false, 25000, 0)
earning.OnOrderPaid(ctx, selfOrder)
assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder))
// A split bill earns once, on the payment that settles it: while partial, nothing.
split := newOrder(earningOutlet, &regular, "partial", false, 60000, 0)
earning.OnOrderPaid(ctx, split)
assert.Empty(t, earnRows(split))
exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split)
earning.OnOrderPaid(ctx, split)
assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split))
// Orders that must not earn.
for name, c := range map[string]struct {
id uuid.UUID
skip string
}{
"walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer},
"inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer},
"no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer},
"void": {newOrder(earningOutlet, &regular, "completed", true, 50000, 0), EarningSkipVoid},
"unpaid": {newOrder(earningOutlet, &regular, "pending", false, 50000, 0), EarningSkipNotPaid},
"outlet not earning": {newOrder(quietOutlet, &regular, "completed", false, 50000, 0), EarningSkipNothingToEarn},
} {
outcome, err := earning.EarnForOrder(ctx, c.id)
require.NoError(t, err, name)
assert.Equal(t, c.skip, outcome.Skipped, name)
assert.Empty(t, earnRows(c.id), name)
}
// An earning that fails does not surface to the payment, and the job picks it up.
settings.mu.Lock()
settings.fail = true
settings.mu.Unlock()
missed := newOrder(earningOutlet, &regular, "completed", false, 40000, 0)
assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) })
assert.Empty(t, earnRows(missed))
settings.mu.Lock()
settings.fail = false
settings.mu.Unlock()
since := time.Now().Add(-time.Hour)
checked, earned, err := earning.EarnMissing(ctx, since, 1000)
require.NoError(t, err)
assert.GreaterOrEqual(t, earned, 1)
assert.GreaterOrEqual(t, checked, earned)
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
// The job only looks at orders that could earn and have not.
candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000)
require.NoError(t, err)
var ours []uuid.UUID
for _, c := range candidates {
var n int64
db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n)
if n > 0 {
ours = append(ours, c.ID)
}
}
assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates")
// A second run finds nothing more to do for these orders.
_, _, err = earning.EarnMissing(ctx, since, 1000)
require.NoError(t, err)
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
// What the order screen and receipt show is what the ledger has.
shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()})
require.NoError(t, err)
assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid])
assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split])
assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed])
assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0")
balance := struct{ PointBalance, CoinBalance int64 }{}
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
}
+167
View File
@@ -0,0 +1,167 @@
package processor
import (
"context"
"fmt"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/repository"
)
// ReversalOutcome is what a reversal took back, and what it could not.
type ReversalOutcome struct {
Points int64
Coins int64
PointShortfall int64
CoinShortfall int64
}
// OnOrderRefunded is called after an order was voided or (partly) refunded and that
// has committed. It never fails the caller: a refund is never blocked by the loyalty
// balance (Q3), so errors are logged.
func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) {
defer func() {
if r := recover(); r != nil {
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r))
}
}()
if _, err := p.ReverseForOrder(ctx, orderID); err != nil {
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err)
}
}
// ReverseForOrder takes back what an order earned, as far as it has been voided or
// refunded (docs/prd-point-coin.md F10):
//
// - void: everything the order earned;
// - refund: floor(earned × refunded / basis), never more than was earned, with the
// refunded amount being the order's cumulative refund.
//
// Only the part not asked back yet is taken, so calling it again, or after each of
// several partial refunds, never takes more than the order earned. It draws from the
// lots the EARN created first, then from the others in K9 order, and takes what the
// balance has when it is short, recording the rest as shortfall (Q3).
func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) {
order, err := p.orders.GetOrderForEarning(ctx, orderID)
if err != nil {
return nil, err
}
earns, err := p.orders.ListEarnTransactions(ctx, orderID)
if err != nil {
return nil, err
}
outcome := &ReversalOutcome{}
if len(earns) == 0 {
return outcome, nil
}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
for _, earn := range earns {
target := earningReversalTarget(order, earn)
requested, err := p.orders.ReversalRequested(ctx, earn.ID)
if err != nil {
return err
}
amount := target - requested
if amount <= 0 {
continue
}
lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID)
if err != nil {
return err
}
reason := "REFUND"
if order.IsVoid {
reason = "VOID"
}
earnID := earn.ID
res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{
WalletEntry: WalletEntry{
CustomerID: earn.CustomerID,
Currency: earn.Currency,
Type: constants.WalletTxTypeEarnReversal,
Amount: amount,
ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: order.ID,
ReversesTransactionID: &earnID,
OutletID: earn.OutletID,
Description: earningReversalDescription(order),
Metadata: entities.Metadata{
"reason": reason,
"refund_amount": order.RefundAmount,
"target": target,
},
// The target only grows with each refund, so each refund gets its own
// key while a retry of the same one replays.
IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target),
},
PreferredLotIDs: lots,
})
if err != nil {
return fmt.Errorf("reversing %s: %w", earn.Currency, err)
}
var taken int64
if res.Transaction != nil {
taken = -res.Transaction.Amount
} else {
// Nothing to take: the ledger has no row to carry the shortfall.
logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{
"order_id": order.ID.String(), "customer_id": earn.CustomerID.String(),
"currency": earn.Currency, "shortfall": res.Shortfall,
}, nil)
}
switch earn.Currency {
case constants.WalletCurrencyPoint:
outcome.Points += taken
outcome.PointShortfall += res.Shortfall
case constants.WalletCurrencyCoin:
outcome.Coins += taken
outcome.CoinShortfall += res.Shortfall
}
}
return nil
})
if err != nil {
return nil, err
}
return outcome, nil
}
// earningReversalTarget is how much of an EARN row should have been taken back in
// total, given the order's void flag and cumulative refund. It works in cents like
// CalculateEarning, with the basis frozen on the EARN row.
func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 {
earned := earn.Amount
if order.IsVoid {
return earned
}
refundCents := toCents(order.RefundAmount)
if refundCents <= 0 {
return 0
}
basis, _ := earn.Metadata["basis"].(float64)
basisCents := toCents(basis)
// A refund can include tax, which the basis does not, so it can reach past it.
if basisCents <= 0 || refundCents >= basisCents {
return earned
}
return earned * refundCents / basisCents
}
func earningReversalDescription(order *repository.EarningOrder) string {
verb := "Refund"
if order.IsVoid {
verb = "Batal"
}
description := verb + " #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
return truncateRunes(description, walletDescriptionLimit)
}
@@ -0,0 +1,206 @@
package processor
import (
"context"
"os"
"testing"
"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/repository"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestEarningReversal_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org, user, outlet := uuid.New(), uuid.New(), uuid.New()
voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New()
customers := []uuid.UUID{voider, refunder, spender, loyal}
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')`, org)
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
for _, c := range customers {
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, c, org)
}
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 ?`, 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 orders WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
db.Exec(`DELETE FROM users WHERE id = ?`, user)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
txm := repository.NewTxManager(db)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
s, err := settings.Outlet(ctx, outlet)
require.NoError(t, err)
s.Point.Enabled, s.Coin.Enabled = true, true
_, err = settings.UpdateOutlet(ctx, org, outlet, user, *s)
require.NoError(t, err)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)
// Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin.
paidOrder := func(customer uuid.UUID) uuid.UUID {
t.Helper()
id := uuid.New()
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
subtotal, discount_amount, tax_amount, total_amount, payment_status)
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`,
id, org, outlet, user, customer, "ORD-"+id.String()[:8])
outcome, err := earning.EarnForOrder(ctx, id)
require.NoError(t, err)
require.Equal(t, int64(875), outcome.Points)
return id
}
balance := func(customer uuid.UUID) (points, coins int64) {
t.Helper()
var b struct{ PointBalance, CoinBalance int64 }
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
return b.PointBalance, b.CoinBalance
}
reversed := func(order uuid.UUID, currency string) int64 {
t.Helper()
var total int64
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error)
return total
}
// Void: everything comes back.
voided := paidOrder(voider)
exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided)
earning.OnOrderRefunded(ctx, voided)
assert.Equal(t, int64(875), reversed(voided, "POINT"))
assert.Equal(t, int64(3), reversed(voided, "COIN"))
p, c := balance(voider)
assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c})
var rev struct {
ReversesTransactionID string
Description string
}
require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error)
var earnID string
require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error)
assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses")
assert.Contains(t, rev.Description, "Batal #ORD-")
// Repeating it takes nothing more.
earning.OnOrderRefunded(ctx, voided)
assert.Equal(t, int64(875), reversed(voided, "POINT"))
// Partial refunds are proportional and add up to at most what was earned.
refunded := paidOrder(refunder)
for _, step := range []struct {
refundAmount float64
wantTotal int64
}{
{20000, 200}, // floor(875 × 20000 / 87500)
{20000, 200}, // same refund again: nothing more
{50000, 500}, // cumulative refund grew
{96250, 875}, // the whole total, tax included, cannot exceed the earning
{96250, 875},
} {
exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded)
outcome, err := earning.ReverseForOrder(ctx, refunded)
require.NoError(t, err)
assert.Zero(t, outcome.PointShortfall)
assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount)
}
assert.Equal(t, int64(3), reversed(refunded, "COIN"))
// Balance already spent: what is left is taken, the rest recorded as shortfall,
// and the refund is not blocked.
spent := paidOrder(spender)
committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Bayar"}})
})
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
outcome, err := earning.ReverseForOrder(ctx, spent)
require.NoError(t, err)
assert.Equal(t, int64(75), outcome.Points)
assert.Equal(t, int64(800), outcome.PointShortfall)
p, _ = balance(spender)
assert.Equal(t, int64(0), p, "never negative")
var metadata string
require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error)
assert.Contains(t, metadata, `"shortfall": 800`)
assert.Contains(t, metadata, `"requested_amount": 875`)
// Asked back in full already, shortfall included: a repeat takes nothing.
outcome, err = earning.ReverseForOrder(ctx, spent)
require.NoError(t, err)
assert.Zero(t, outcome.Points)
// The EARN's own lots are drawn first, even when an older lot would come first.
older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
})
loyalOrder := paidOrder(loyal)
exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder)
_, err = earning.ReverseForOrder(ctx, loyalOrder)
require.NoError(t, err)
var fromOlder int64
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a
JOIN wallet_transactions t ON t.id = a.transaction_id
WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error)
assert.Zero(t, fromOlder, "the older lot is untouched")
assert.Equal(t, int64(200), reversed(loyalOrder, "POINT"))
// An order that never earned has nothing to reverse.
outcome, err = earning.ReverseForOrder(ctx, uuid.New())
assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound)
assert.Nil(t, outcome)
// Every reversal kept balances, ledger and lots in step (§7.5).
found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000)
require.NoError(t, err)
for _, d := range found {
for _, c := range customers {
assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check)
}
}
}
// committed runs fn in a transaction that commits before it returns, for engine calls
// made directly from a test.
func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T {
t.Helper()
var out T
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
var err error
out, err = fn(ctx)
return err
}))
return out
}
@@ -0,0 +1,25 @@
package processor
import (
"testing"
"github.com/stretchr/testify/assert"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
func TestEarningReversalTarget(t *testing.T) {
earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}}
target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) }
assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take")
assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything")
assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)")
assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round")
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500}))
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned")
noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}}
assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all")
}
@@ -0,0 +1,442 @@
package processor
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrInvalidLoyaltySettings wraps every rejection of a setting that breaks the rules in
// docs/prd-point-coin.md F1, F2 and F12.
var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
// LoyaltySettingsProcessor reads and writes the loyalty settings of outlets (F1) and
// organizations (F2, F12). Reads always return every setting, with the PRD default for
// a key that was never set, so callers never handle a missing value. Every write
// records what changed in loyalty_setting_changes.
type LoyaltySettingsProcessor struct {
repo repository.LoyaltySettingsRepository
tx TxRunner
}
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
}
// Outlet returns an outlet's settings.
func (p *LoyaltySettingsProcessor) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
raw, err := p.repo.GetOutletValues(ctx, outletID)
if err != nil {
return nil, err
}
var s models.OutletLoyaltySettings
loadLoyaltyFields(outletLoyaltyFields(&s), raw, "outlet "+outletID.String())
return &s, nil
}
// OutletForOrganization returns an outlet's settings for the dashboard, and
// repository.ErrLoyaltyOutletNotFound unless the outlet belongs to the organization.
func (p *LoyaltySettingsProcessor) OutletForOrganization(ctx context.Context, organizationID, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
if err != nil {
return nil, err
}
if owner != organizationID {
return nil, repository.ErrLoyaltyOutletNotFound
}
return p.Outlet(ctx, outletID)
}
// Organization returns an organization's settings.
func (p *LoyaltySettingsProcessor) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
if err != nil {
return nil, err
}
var s models.OrganizationLoyaltySettings
loadLoyaltyFields(organizationLoyaltyFields(&s), raw, "organization "+organizationID.String())
return &s, nil
}
// PointValue is the rupiah value of one EnakPoint in an organization.
func (p *LoyaltySettingsProcessor) PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error) {
s, err := p.Organization(ctx, organizationID)
if err != nil {
return 0, err
}
return s.PointValue, nil
}
// UpdateOutlet replaces an outlet's settings and returns what changed. The outlet must
// belong to the organization; otherwise repository.ErrLoyaltyOutletNotFound.
func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizationID, outletID, changedBy uuid.UUID, settings models.OutletLoyaltySettings) ([]models.LoyaltySettingChange, error) {
if err := validateLoyaltyFields(outletLoyaltyFields(&settings)); err != nil {
return nil, err
}
var changes []models.LoyaltySettingChange
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
owner, err := p.repo.OutletOrganizationID(ctx, outletID)
if err != nil {
return err
}
if owner != organizationID {
return repository.ErrLoyaltyOutletNotFound
}
if err := p.repo.LockScope(ctx, outletID); err != nil {
return err
}
raw, err := p.repo.GetOutletValues(ctx, outletID)
if err != nil {
return err
}
var current models.OutletLoyaltySettings
loadLoyaltyFields(outletLoyaltyFields(&current), raw, "outlet "+outletID.String())
changes, err = p.apply(ctx, organizationID, &outletID, changedBy, raw,
outletLoyaltyFields(&current), outletLoyaltyFields(&settings),
func(key string, value *string) error { return p.repo.SetOutletValue(ctx, outletID, key, value) })
return err
})
return changes, err
}
// UpdateOrganization replaces an organization's settings and returns what changed.
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
}
var changes []models.LoyaltySettingChange
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.repo.LockScope(ctx, organizationID); err != nil {
return err
}
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
if err != nil {
return err
}
var current models.OrganizationLoyaltySettings
loadLoyaltyFields(organizationLoyaltyFields(&current), raw, "organization "+organizationID.String())
changes, err = p.apply(ctx, organizationID, nil, changedBy, raw,
organizationLoyaltyFields(&current), organizationLoyaltyFields(&settings),
func(key string, value *string) error {
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
})
return err
})
return changes, err
}
// apply stores every field whose effective value changes and records it. A key left
// on its default and not changed is not written, so the history only shows real
// changes. current and next list the same keys in the same order.
func (p *LoyaltySettingsProcessor) apply(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField, store func(key string, value *string) error) ([]models.LoyaltySettingChange, error) {
rows := diffLoyaltyFields(organizationID, outletID, changedBy, raw, current, next)
for _, row := range rows {
if err := store(row.Key, row.NewValue); err != nil {
return nil, err
}
}
if err := p.repo.InsertChanges(ctx, rows); err != nil {
return nil, err
}
changes := make([]models.LoyaltySettingChange, 0, len(rows))
for _, row := range rows {
changes = append(changes, loyaltySettingChangeModel(row))
}
return changes, nil
}
// ListChanges returns a page of an organization's settings history, newest first.
// A nil outlet lists the whole organization, outlets included.
func (p *LoyaltySettingsProcessor) ListChanges(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, page, limit int) (*models.PaginatedResponse[models.LoyaltySettingChange], error) {
if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20
}
rows, total, err := p.repo.ListChanges(ctx, repository.LoyaltySettingChangeFilter{
OrganizationID: organizationID, OutletID: outletID, Offset: (page - 1) * limit, Limit: limit,
})
if err != nil {
return nil, err
}
items := make([]models.LoyaltySettingChange, 0, len(rows))
for _, row := range rows {
items = append(items, loyaltySettingChangeModel(row))
}
return &models.PaginatedResponse[models.LoyaltySettingChange]{
Data: items,
Pagination: models.Pagination{
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
},
}, nil
}
func loyaltySettingChangeModel(row entities.LoyaltySettingChange) models.LoyaltySettingChange {
return models.LoyaltySettingChange{
ID: row.ID,
OrganizationID: row.OrganizationID,
OutletID: row.OutletID,
Key: row.Key,
OldValue: row.OldValue,
NewValue: row.NewValue,
ChangedBy: row.ChangedBy,
CreatedAt: row.CreatedAt,
}
}
// outletLoyaltyFields describes every outlet key (F1), bound to s.
func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
return []loyaltyField{
boolLoyaltyField(constants.LoyaltyPointEnabledKey, &s.Point.Enabled, false),
intLoyaltyField(constants.LoyaltyPointEarnPerAmountKey, &s.Point.EarnPerAmount, constants.LoyaltyPointEarnPerAmountDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyPointEarnValueKey, &s.Point.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyPointMinOrderAmountKey, &s.Point.MinOrderAmount, 0, 0, noLoyaltyMax),
optionalIntLoyaltyField(constants.LoyaltyPointMaxPerOrderKey, &s.Point.MaxPerOrder, 0),
boolLoyaltyField(constants.LoyaltyCoinEnabledKey, &s.Coin.Enabled, false),
intLoyaltyField(constants.LoyaltyCoinEarnPerAmountKey, &s.Coin.EarnPerAmount, constants.LoyaltyCoinEarnPerAmountDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyCoinEarnValueKey, &s.Coin.EarnValue, constants.LoyaltyEarnValueDefault, 0, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
}
}
// organizationLoyaltyFields describes every organization key (F2, F12), bound to s.
func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyField {
fields := []loyaltyField{
intLoyaltyField(constants.LoyaltyPointValueKey, &s.PointValue, constants.LoyaltyPointValueDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyExchangeCoinAmountKey, &s.Exchange.CoinAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyExchangePointAmountKey, &s.Exchange.PointAmount, constants.LoyaltyExchangeAmountDefault, 1, noLoyaltyMax),
boolLoyaltyField(constants.LoyaltyTransferEnabledKey, &s.Transfer.Enabled, constants.LoyaltyTransferEnabledDefault),
intLoyaltyField(constants.LoyaltyTransferMinAmountKey, &s.Transfer.MinAmount, constants.LoyaltyTransferMinAmountDefault, 1, noLoyaltyMax),
optionalIntLoyaltyField(constants.LoyaltyTransferMaxPerTransactionKey, &s.Transfer.MaxPerTransaction, 1),
optionalIntLoyaltyField(constants.LoyaltyTransferDailyLimitKey, &s.Transfer.DailyLimit, 1),
}
fields = append(fields, expiryLoyaltyFields("loyalty.point.", &s.PointExpiry)...)
return append(fields, expiryLoyaltyFields("loyalty.coin.", &s.CoinExpiry)...)
}
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
return []loyaltyField{
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
boolLoyaltyField(prefix+constants.LoyaltyExpiryEndOfMonthSuffix, &s.EndOfMonth, false),
intLoyaltyField(prefix+constants.LoyaltyExpiryReminderDaysSuffix, &s.ReminderDays, constants.LoyaltyExpiryReminderDaysDefault, 0, noLoyaltyMax),
}
}
// loyaltyField is one setting key bound to the struct field that holds it. The same
// description drives reading (parse or default), validating, and writing (format).
type loyaltyField struct {
key string
// parse sets the field from a stored value, or reports it unusable.
parse func(raw string) bool
// reset sets the field to its default.
reset func()
// validate checks the field's current value.
validate func() error
// format is the field's current value as stored; nil means no stored value.
format func() *string
}
const noLoyaltyMax = int64(-1)
func intLoyaltyField(key string, v *int64, def, min, max int64) loyaltyField {
check := func(n int64) error {
if n < min {
return fmt.Errorf("%w: %s must be at least %d", ErrInvalidLoyaltySettings, key, min)
}
if max != noLoyaltyMax && n > max {
return fmt.Errorf("%w: %s must be at most %d", ErrInvalidLoyaltySettings, key, max)
}
return nil
}
return loyaltyField{
key: key,
parse: func(raw string) bool {
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || check(n) != nil {
return false
}
*v = n
return true
},
reset: func() { *v = def },
validate: func() error { return check(*v) },
format: func() *string { s := strconv.FormatInt(*v, 10); return &s },
}
}
// optionalIntLoyaltyField is a limit that may be unset, meaning no limit.
func optionalIntLoyaltyField(key string, v **int64, min int64) loyaltyField {
return loyaltyField{
key: key,
parse: func(raw string) bool {
n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || n < min {
return false
}
*v = &n
return true
},
reset: func() { *v = nil },
validate: func() error {
if *v != nil && **v < min {
return fmt.Errorf("%w: %s must be at least %d, or empty for no limit", ErrInvalidLoyaltySettings, key, min)
}
return nil
},
format: func() *string {
if *v == nil {
return nil
}
s := strconv.FormatInt(**v, 10)
return &s
},
}
}
func boolLoyaltyField(key string, v *bool, def bool) loyaltyField {
return loyaltyField{
key: key,
parse: func(raw string) bool {
b, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return false
}
*v = b
return true
},
reset: func() { *v = def },
validate: func() error { return nil },
format: func() *string { s := strconv.FormatBool(*v); return &s },
}
}
func enumLoyaltyField(key string, v *string, def string, allowed ...string) loyaltyField {
valid := func(s string) bool {
for _, a := range allowed {
if s == a {
return true
}
}
return false
}
return loyaltyField{
key: key,
parse: func(raw string) bool {
s := strings.ToUpper(strings.TrimSpace(raw))
if !valid(s) {
return false
}
*v = s
return true
},
reset: func() { *v = def },
validate: func() error {
if !valid(*v) {
return fmt.Errorf("%w: %s must be one of %s", ErrInvalidLoyaltySettings, key, strings.Join(allowed, ", "))
}
return nil
},
format: func() *string { s := *v; return &s },
}
}
// loadLoyaltyFields fills each field from its stored value, or its default when there
// is none. A stored value that cannot be used also falls back to the default, so a bad
// row can never reach a calculation, and it is logged so it gets fixed.
func loadLoyaltyFields(fields []loyaltyField, raw map[string]string, scope string) {
for _, f := range fields {
f.reset()
value, ok := raw[f.key]
if !ok {
continue
}
if !f.parse(value) {
f.reset()
logger.NonContext.Warnf("Loyalty setting %s of %s has an unusable value %q; using the default", f.key, scope, value)
}
}
}
func validateLoyaltyFields(fields []loyaltyField) error {
for _, f := range fields {
if err := f.validate(); err != nil {
return err
}
}
return nil
}
func equalOptionalStrings(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
// diffLoyaltyFields lists the keys whose effective value differs between current and
// next, with the stored old value (nil when the key was on its default). current and
// next list the same keys in the same order.
func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy uuid.UUID, raw map[string]string, current, next []loyaltyField) []entities.LoyaltySettingChange {
var rows []entities.LoyaltySettingChange
for i, field := range next {
newValue := field.format()
if equalOptionalStrings(current[i].format(), newValue) {
continue
}
var oldValue *string
if v, ok := raw[field.key]; ok {
oldValue = &v
}
rows = append(rows, entities.LoyaltySettingChange{
OrganizationID: organizationID,
OutletID: outletID,
Key: field.key,
OldValue: oldValue,
NewValue: newValue,
ChangedBy: changedBy,
})
}
return rows
}
// PreviewOrganization validates new organization settings and lists what saving them
// would change, without writing anything.
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
return nil, err
}
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
if err != nil {
return nil, err
}
var current models.OrganizationLoyaltySettings
loadLoyaltyFields(organizationLoyaltyFields(&current), raw, "organization "+organizationID.String())
rows := diffLoyaltyFields(organizationID, nil, uuid.Nil, raw, organizationLoyaltyFields(&current), organizationLoyaltyFields(&settings))
changes := make([]models.LoyaltySettingChange, 0, len(rows))
for _, row := range rows {
changes = append(changes, loyaltySettingChangeModel(row))
}
return changes, nil
}
@@ -0,0 +1,322 @@
package processor
import (
"context"
"os"
"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/logger"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func TestMain(m *testing.M) {
// Some processors log; the server sets the logger up at startup.
logger.Setup("fatal", "json")
os.Exit(m.Run())
}
// loyaltyRepoFake keeps settings in maps. It does not check for a transaction.
type loyaltyRepoFake struct {
outletOrg map[uuid.UUID]uuid.UUID
outletValues map[string]string
orgValues map[string]string
changes []entities.LoyaltySettingChange
locks []uuid.UUID
}
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.outletValues), nil
}
func (f *loyaltyRepoFake) GetOrganizationValues(context.Context, uuid.UUID) (map[string]string, error) {
return copyStringMap(f.orgValues), nil
}
func (f *loyaltyRepoFake) OutletOrganizationID(_ context.Context, outletID uuid.UUID) (uuid.UUID, error) {
org, ok := f.outletOrg[outletID]
if !ok {
return uuid.Nil, repository.ErrLoyaltyOutletNotFound
}
return org, nil
}
func (f *loyaltyRepoFake) LockScope(_ context.Context, id uuid.UUID) error {
f.locks = append(f.locks, id)
return nil
}
func (f *loyaltyRepoFake) SetOutletValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.outletValues = setOptional(f.outletValues, key, value)
return nil
}
func (f *loyaltyRepoFake) SetOrganizationValue(_ context.Context, _ uuid.UUID, key string, value *string) error {
f.orgValues = setOptional(f.orgValues, key, value)
return nil
}
func (f *loyaltyRepoFake) InsertChanges(_ context.Context, changes []entities.LoyaltySettingChange) error {
f.changes = append(f.changes, changes...)
return nil
}
func (f *loyaltyRepoFake) ListChanges(context.Context, repository.LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
return f.changes, int64(len(f.changes)), nil
}
func copyStringMap(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func setOptional(m map[string]string, key string, value *string) map[string]string {
if m == nil {
m = map[string]string{}
}
if value == nil {
delete(m, key)
} else {
m[key] = *value
}
return m
}
// txRunnerFake runs fn in place.
type txRunnerFake struct{}
func (txRunnerFake) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
return fn(ctx)
}
func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
s, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, models.OutletLoyaltySettings{
Point: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 100, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnPerAmount: 25000, EarnValue: 1, MinOrderAmount: 0, MaxPerOrder: nil},
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
}, *s)
}
func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.T) {
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
s, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
assert.Equal(t, models.OrganizationLoyaltySettings{
PointValue: 1,
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
PointExpiry: expiry,
CoinExpiry: expiry,
}, *s)
}
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
constants.LoyaltyPointMaxPaymentPercentKey: "50",
"loyalty.unknown": "ignored",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "100",
constants.LoyaltyTransferDailyLimitKey: "10000",
"loyalty.coin.expiry_unit": "day",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.True(t, outlet.Point.Enabled)
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
org, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), org.PointValue)
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
}
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "yes please",
constants.LoyaltyPointEarnPerAmountKey: "0",
constants.LoyaltyCoinEarnValueKey: "-1",
constants.LoyaltyPointMaxPerOrderKey: "abc",
constants.LoyaltyPointMaxPaymentPercentKey: "150",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "0",
"loyalty.point.expiry_unit": "YEAR",
"loyalty.point.expiry_period": "",
},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
outlet, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.False(t, outlet.Point.Enabled)
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
assert.Nil(t, outlet.Point.MaxPerOrder)
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
repo.orgValues = map[string]string{}
if raw != "" {
repo.orgValues[constants.LoyaltyPointValueKey] = raw
}
value, err := p.PointValue(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, value, name)
}
}
func TestLoyaltySettings_UpdateRecordsOnlyRealChanges(t *testing.T) {
org, outlet, admin := uuid.New(), uuid.New(), uuid.New()
repo := &loyaltyRepoFake{
outletOrg: map[uuid.UUID]uuid.UUID{outlet: org},
outletValues: map[string]string{constants.LoyaltyPointEarnPerAmountKey: "200"},
}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
settings.Point.Enabled = true // default false -> true
settings.Point.EarnPerAmount = 100 // stored 200 -> 100 (the default, but set explicitly)
settings.Point.MaxPerOrder = ptr(int64(0)) // unset -> 0
// Everything else stays on its default.
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
require.NoError(t, err)
byKey := map[string]models.LoyaltySettingChange{}
for _, c := range changes {
byKey[c.Key] = c
assert.Equal(t, admin, c.ChangedBy)
assert.Equal(t, org, c.OrganizationID)
assert.Equal(t, &outlet, c.OutletID)
}
require.Len(t, byKey, 3, "only the three keys that changed")
assert.Nil(t, byKey[constants.LoyaltyPointEnabledKey].OldValue, "was on its default")
assert.Equal(t, "true", *byKey[constants.LoyaltyPointEnabledKey].NewValue)
assert.Equal(t, "200", *byKey[constants.LoyaltyPointEarnPerAmountKey].OldValue)
assert.Equal(t, "100", *byKey[constants.LoyaltyPointEarnPerAmountKey].NewValue)
assert.Equal(t, "0", *byKey[constants.LoyaltyPointMaxPerOrderKey].NewValue)
assert.Len(t, repo.changes, 3)
assert.Equal(t, []uuid.UUID{outlet}, repo.locks)
after, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
assert.Equal(t, *settings, *after)
// Saving the same settings again changes nothing and records nothing.
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
assert.Empty(t, changes)
assert.Len(t, repo.changes, 3)
// Clearing a limit removes the stored value, recorded as a change to nothing.
after.Point.MaxPerOrder = nil
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *after)
require.NoError(t, err)
require.Len(t, changes, 1)
assert.Equal(t, "0", *changes[0].OldValue)
assert.Nil(t, changes[0].NewValue)
assert.NotContains(t, repo.outletValues, constants.LoyaltyPointMaxPerOrderKey)
}
func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
org, admin := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
settings, err := p.Organization(ctx, org)
require.NoError(t, err)
settings.PointValue = 100
settings.CoinExpiry.Enabled = true
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
require.NoError(t, err)
keys := []string{}
for _, c := range changes {
keys = append(keys, c.Key)
assert.Nil(t, c.OutletID, "organization settings have no outlet")
}
assert.ElementsMatch(t, []string{constants.LoyaltyPointValueKey, "loyalty.coin.expiry_enabled", "loyalty.coin.expiry_unit"}, keys)
value, err := p.PointValue(ctx, org)
require.NoError(t, err)
assert.Equal(t, int64(100), value)
}
func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
org, outlet := uuid.New(), uuid.New()
repo := &loyaltyRepoFake{outletOrg: map[uuid.UUID]uuid.UUID{outlet: org}}
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
ctx := context.Background()
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
} {
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOutlet(ctx, org, outlet, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
for name, mutate := range map[string]func(*models.OrganizationLoyaltySettings){
"point value 0": func(s *models.OrganizationLoyaltySettings) { s.PointValue = 0 },
"exchange coin amount 0": func(s *models.OrganizationLoyaltySettings) { s.Exchange.CoinAmount = 0 },
"transfer min 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.MinAmount = 0 },
"daily limit 0": func(s *models.OrganizationLoyaltySettings) { s.Transfer.DailyLimit = ptr(int64(0)) },
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
} {
s, err := p.Organization(ctx, org)
require.NoError(t, err)
mutate(s)
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
}
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
assert.Empty(t, repo.locks, "invalid input is rejected before touching the database")
// An outlet of another organization.
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
_, err = p.UpdateOutlet(ctx, uuid.New(), outlet, uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
}
+272
View File
@@ -0,0 +1,272 @@
package processor
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// These fakes embed the interface they stand for and implement only what the paths
// under test call; anything else would panic, which would show a path doing more than
// expected.
type hookOrderRepo struct {
OrderRepository
order *entities.Order
statusUpdates int
hookCallsAtPay int // hook calls seen when the status was written
hook *orderPaidHookFake
}
func (r *hookOrderRepo) GetByID(context.Context, uuid.UUID) (*entities.Order, error) {
o := *r.order
return &o, nil
}
func (r *hookOrderRepo) GetWithRelations(context.Context, uuid.UUID) (*entities.Order, error) {
o := *r.order
return &o, nil
}
func (r *hookOrderRepo) UpdateStatusSuccess(_ context.Context, _ uuid.UUID, status entities.OrderStatus, payment entities.PaymentStatus) error {
r.statusUpdates++
r.hookCallsAtPay = len(r.hook.calls)
r.order.Status, r.order.PaymentStatus = status, payment
return nil
}
type hookPaymentRepo struct {
PaymentRepository
created []*entities.Payment
}
func (r *hookPaymentRepo) GetTotalPaidByOrderID(context.Context, uuid.UUID) (float64, error) {
return 0, nil
}
func (r *hookPaymentRepo) Create(_ context.Context, p *entities.Payment) error {
p.ID = uuid.New()
r.created = append(r.created, p)
return nil
}
func (r *hookPaymentRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.Payment, error) {
for _, p := range r.created {
if p.ID == id {
return p, nil
}
}
return nil, nil
}
func (r *hookPaymentRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.Payment, error) {
return r.created, nil
}
type hookPaymentMethodRepo struct{}
func (hookPaymentMethodRepo) GetByID(_ context.Context, id uuid.UUID) (*entities.PaymentMethod, error) {
return &entities.PaymentMethod{ID: id}, nil
}
type hookOrderItemRepo struct{ OrderItemRepository }
func (hookOrderItemRepo) GetByOrderID(context.Context, uuid.UUID) ([]*entities.OrderItem, error) {
return nil, nil
}
// splitFake settles the order on the payment that covers what is left, as the real
// split bill processor does.
type splitFake struct{ settle bool }
func (f splitFake) split(order *entities.Order) (*models.SplitBillResponse, error) {
if f.settle {
order.PaymentStatus = entities.PaymentStatusCompleted
} else {
order.PaymentStatus = entities.PaymentStatusPartial
}
return &models.SplitBillResponse{OrderID: order.ID}, nil
}
func (f splitFake) SplitByAmount(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
return f.split(order)
}
func (f splitFake) SplitByItem(_ context.Context, _ *models.SplitBillRequest, order *entities.Order, _ *entities.PaymentMethod, _ *entities.Customer) (*models.SplitBillResponse, error) {
return f.split(order)
}
type orderPaidHookFake struct {
calls []uuid.UUID
ctxs []context.Context
refunded []uuid.UUID
earned map[uuid.UUID]OrderEarned
}
func (h *orderPaidHookFake) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
h.calls = append(h.calls, orderID)
h.ctxs = append(h.ctxs, ctx)
}
func (h *orderPaidHookFake) OnOrderRefunded(_ context.Context, orderID uuid.UUID) {
h.refunded = append(h.refunded, orderID)
}
func (h *orderPaidHookFake) EarnedByOrders(context.Context, []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
return h.earned, nil
}
func newHookedOrderProcessor(split SplitBillProcessor) (*OrderProcessorImpl, *hookOrderRepo, *orderPaidHookFake) {
hook := &orderPaidHookFake{}
orders := &hookOrderRepo{
order: &entities.Order{ID: uuid.New(), OrganizationID: uuid.New(), OutletID: uuid.New(), TotalAmount: 100000, PaymentStatus: entities.PaymentStatusPending},
hook: hook,
}
p := &OrderProcessorImpl{
orderRepo: orders,
orderItemRepo: hookOrderItemRepo{},
paymentRepo: &hookPaymentRepo{},
paymentMethodRepo: hookPaymentMethodRepo{},
splitBillProcessor: split,
txManager: repository.NewTxManager(nil),
}
p.SetLoyalty(hook)
return p, orders, hook
}
func TestOrderPaidHook_CreatePayment(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
ctx, cancel := context.WithCancel(context.Background())
_, err := p.CreatePayment(ctx, &models.CreatePaymentRequest{OrderID: orders.order.ID, PaymentMethodID: uuid.New(), Amount: 100000})
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
assert.Zero(t, orders.hookCallsAtPay, "the hook runs after the payment, not inside its transaction")
// The hook's context outlives the request.
cancel()
assert.NoError(t, hook.ctxs[0].Err())
}
func TestOrderPaidHook_UpdateOrder(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
assert.Equal(t, 1, orders.statusUpdates)
assert.Zero(t, orders.hookCallsAtPay)
}
func TestOrderPaidHook_SplitBillOnlyOnTheSettlingPayment(t *testing.T) {
for _, splitType := range []string{"AMOUNT", "ITEM"} {
t.Run(splitType, func(t *testing.T) {
req := &models.SplitBillRequest{Type: splitType, PaymentMethodID: uuid.New()}
p, orders, hook := newHookedOrderProcessor(splitFake{settle: false})
req.OrderID = orders.order.ID
_, err := p.SplitBill(context.Background(), req)
require.NoError(t, err)
assert.Empty(t, hook.calls, "a partial split payment does not make the order paid")
p, orders, hook = newHookedOrderProcessor(splitFake{settle: true})
req.OrderID = orders.order.ID
_, err = p.SplitBill(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.calls)
})
}
}
func TestOrderPaidHook_NoHookIsFine(t *testing.T) {
p, orders, _ := newHookedOrderProcessor(nil)
p.SetLoyalty(nil)
_, err := p.UpdateOrder(context.Background(), orders.order.ID, &models.UpdateOrderRequest{})
assert.NoError(t, err)
}
func (r *hookOrderRepo) VoidOrderWithStatus(_ context.Context, _ uuid.UUID, status entities.OrderStatus, _ string, _ uuid.UUID) error {
r.order.Status, r.order.IsVoid = status, true
return nil
}
func (r *hookOrderRepo) Update(_ context.Context, o *entities.Order) error {
r.order.RefundAmount = o.RefundAmount
return nil
}
func (r *hookOrderRepo) RefundOrder(context.Context, uuid.UUID, string, uuid.UUID) error {
r.order.IsRefund = true
return nil
}
func (r *hookPaymentRepo) RefundPayment(_ context.Context, id uuid.UUID, amount float64, _ string, _ uuid.UUID) error {
for _, p := range r.created {
if p.ID == id {
p.RefundAmount = amount
}
}
return nil
}
func TestOrderRefundedHook(t *testing.T) {
t.Run("void", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
require.NoError(t, p.VoidOrder(context.Background(), &models.VoidOrderRequest{OrderID: orders.order.ID, Type: "ALL", Reason: "salah input"}, uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
})
t.Run("refund order", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
orders.order.PaymentStatus = entities.PaymentStatusCompleted
amount := 25000.0
require.NoError(t, p.RefundOrder(context.Background(), orders.order.ID, &models.RefundOrderRequest{RefundAmount: &amount}, uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
})
t.Run("refund payment", func(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
payments := p.paymentRepo.(*hookPaymentRepo)
payment := &entities.Payment{ID: uuid.New(), OrderID: orders.order.ID, Amount: 50000, Status: entities.PaymentTransactionStatusCompleted}
payments.created = append(payments.created, payment)
require.NoError(t, p.RefundPayment(context.Background(), payment.ID, 20000, "kembali", uuid.New()))
assert.Equal(t, []uuid.UUID{orders.order.ID}, hook.refunded)
// A refund that fails does not run the hook.
assert.Error(t, p.RefundPayment(context.Background(), payment.ID, 60000, "terlalu besar", uuid.New()))
assert.Len(t, hook.refunded, 1)
})
}
func (r *hookOrderRepo) List(context.Context, map[string]interface{}, int, int) ([]*entities.Order, int64, error) {
o := *r.order
other := entities.Order{ID: uuid.New()}
return []*entities.Order{&o, &other}, 2, nil
}
func TestOrderResponsesCarryEarnings(t *testing.T) {
p, orders, hook := newHookedOrderProcessor(nil)
hook.earned = map[uuid.UUID]OrderEarned{orders.order.ID: {Points: 875, Coins: 3}}
got, err := p.GetOrderByID(context.Background(), orders.order.ID)
require.NoError(t, err)
assert.Equal(t, int64(875), got.PointsEarned)
assert.Equal(t, int64(3), got.CoinsEarned)
list, err := p.ListOrders(context.Background(), &models.ListOrdersRequest{Page: 1, Limit: 10})
require.NoError(t, err)
require.Len(t, list.Orders, 2)
assert.Equal(t, int64(875), list.Orders[0].PointsEarned)
assert.Equal(t, int64(0), list.Orders[1].PointsEarned, "an order that earned nothing shows 0")
// Without the loyalty program the fields stay at 0.
p.SetLoyalty(nil)
got, err = p.GetOrderByID(context.Background(), orders.order.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), got.PointsEarned)
}
+160 -25
View File
@@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
@@ -108,6 +110,78 @@ type OrderProcessorImpl struct {
ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
loyalty OrderLoyalty
}
// OrderLoyalty is what the order flow tells and asks the loyalty program
// (docs/prd-point-coin.md F3, F10). EarningProcessor is one.
type OrderLoyalty interface {
// OnOrderPaid runs once an order has just become fully paid and the payment has
// committed.
OnOrderPaid(ctx context.Context, orderID uuid.UUID)
// OnOrderRefunded runs once an order has been voided or (partly) refunded and that
// has committed.
OnOrderRefunded(ctx context.Context, orderID uuid.UUID)
// EarnedByOrders returns what each order earned.
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error)
}
// OrderEarned is what one order earned, as written in its EARN rows.
type OrderEarned struct {
Points int64
Coins int64
}
// SetLoyalty connects the order flow to the loyalty program.
func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
p.loyalty = loyalty
}
// 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
// transaction and from the request being cancelled, and anything it does cannot fail
// the payment.
func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID) {
if p.loyalty == nil {
return
}
p.loyalty.OnOrderPaid(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
}
// onOrderRefunded is called at the end of VoidOrder, RefundOrder and RefundPayment,
// 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
}
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
}
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
// leaves them at zero rather than failing the order read.
func (p *OrderProcessorImpl) attachEarnings(ctx context.Context, responses ...*models.OrderResponse) {
if p.loyalty == nil || len(responses) == 0 {
return
}
ids := make([]uuid.UUID, 0, len(responses))
for _, r := range responses {
if r != nil {
ids = append(ids, r.ID)
}
}
earned, err := p.loyalty.EarnedByOrders(repository.DetachTransaction(ctx), ids)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::attachEarnings -> failed to read earnings")
return
}
for _, r := range responses {
if r != nil {
e := earned[r.ID]
r.PointsEarned, r.CoinsEarned = e.Points, e.Coins
}
}
}
func NewOrderProcessorImpl(
@@ -149,6 +223,33 @@ func NewOrderProcessorImpl(
}
}
// resolveLineQuantity reconciles the requested quantity and weight against how the
// product is sold, returning the values to store on the order line.
//
// For weight-based products a line is exactly one weighing, so quantity is pinned to 1
// and the weighed amount is what gets priced. This mirrors
// chk_order_items_weight_single_line in the database.
func resolveLineQuantity(product *entities.Product, quantity int, weight *float64) (int, *float64, error) {
if product.SellBy == constants.SellByWeight {
if weight == nil {
return 0, nil, fmt.Errorf("product %s is sold by weight and requires a weight", product.Name)
}
rounded := entities.RoundWeight(*weight)
if rounded <= 0 {
return 0, nil, fmt.Errorf("weight for product %s must be greater than 0", product.Name)
}
return 1, &rounded, nil
}
if weight != nil {
return 0, nil, fmt.Errorf("product %s is not sold by weight and must not carry a weight", product.Name)
}
if quantity < 1 {
return 0, nil, fmt.Errorf("quantity for product %s must be at least 1", product.Name)
}
return quantity, nil, nil
}
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
if err != nil {
@@ -194,25 +295,29 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
}
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
itemTotalCost := float64(itemReq.Quantity) * unitCost
subtotal += itemTotalPrice
totalCost += itemTotalCost
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
if err != nil {
return nil, err
}
orderItem := &entities.OrderItem{
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
Quantity: quantity,
Weight: weight,
UnitID: product.UnitID,
UnitPrice: unitPrice,
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
orderItem.CalculateTotalPrice()
orderItem.CalculateTotalCost()
subtotal += orderItem.TotalPrice
totalCost += orderItem.TotalCost
orderItems = append(orderItems, orderItem)
}
@@ -265,6 +370,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -327,26 +433,30 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}
}
itemTotalPrice := float64(itemReq.Quantity) * unitPrice
itemTotalCost := float64(itemReq.Quantity) * unitCost
newSubtotal += itemTotalPrice
newTotalCost += itemTotalCost
quantity, weight, err := resolveLineQuantity(product, itemReq.Quantity, itemReq.Weight)
if err != nil {
return nil, err
}
orderItem := &entities.OrderItem{
OrderID: orderID,
ProductID: itemReq.ProductID,
ProductVariantID: itemReq.ProductVariantID,
Quantity: itemReq.Quantity,
Quantity: quantity,
Weight: weight,
UnitID: product.UnitID,
UnitPrice: unitPrice,
TotalPrice: itemTotalPrice,
UnitCost: unitCost,
TotalCost: itemTotalCost,
Modifiers: entities.Modifiers(itemReq.Modifiers),
Notes: itemReq.Notes,
Metadata: entities.Metadata(itemReq.Metadata),
Status: entities.OrderItemStatusPending,
}
orderItem.CalculateTotalPrice()
orderItem.CalculateTotalCost()
newSubtotal += orderItem.TotalPrice
newTotalCost += orderItem.TotalCost
addedOrderItems = append(addedOrderItems, orderItem)
}
@@ -399,6 +509,7 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
}
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, updatedOrderResponse)
return &models.AddToOrderResponse{
OrderID: orderID,
@@ -458,6 +569,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
if err := p.orderRepo.UpdateStatusSuccess(ctx, order.ID, order.Status, order.PaymentStatus); err != nil {
return nil, fmt.Errorf("failed to update order: %w", err)
}
p.onOrderPaid(ctx, order.ID)
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, id)
if err != nil {
@@ -465,6 +577,7 @@ func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req
}
response := mappers.OrderEntityToResponse(orderWithRelations)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -475,6 +588,7 @@ func (p *OrderProcessorImpl) GetOrderByID(ctx context.Context, id uuid.UUID) (*m
}
response := mappers.OrderEntityToResponse(order)
p.attachEarnings(ctx, response)
return response, nil
}
@@ -536,6 +650,11 @@ func (p *OrderProcessorImpl) ListOrders(ctx context.Context, req *models.ListOrd
}
}
}
listed := make([]*models.OrderResponse, len(orderResponses))
for i := range orderResponses {
listed[i] = &orderResponses[i]
}
p.attachEarnings(ctx, listed...)
totalPages := int(total) / req.Limit
if int(total)%req.Limit > 0 {
@@ -602,8 +721,9 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("void quantity cannot exceed original quantity for item %d", itemVoid.OrderItemID)
}
voidedAmount := float64(itemVoid.Quantity) * orderItem.UnitPrice
voidedCost := float64(itemVoid.Quantity) * orderItem.UnitCost
billable := orderItem.BillableQuantityFor(itemVoid.Quantity)
voidedAmount := entities.RoundMoney(billable * orderItem.UnitPrice)
voidedCost := entities.RoundMoney(billable * orderItem.UnitCost)
totalVoidedAmount += voidedAmount
totalVoidedCost += voidedCost
@@ -655,6 +775,10 @@ func (p *OrderProcessorImpl) VoidOrder(ctx context.Context, req *models.VoidOrde
return fmt.Errorf("invalid void type: must be 'ALL' or 'ITEM'")
}
// VoidOrder refuses fully paid orders today, so there is usually nothing to take
// back; this keeps a void correct if that rule changes. The order repository writes
// outside the caller's transaction, so the void is already committed here.
p.onOrderRefunded(ctx, req.OrderID)
return nil
}
@@ -720,7 +844,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
return fmt.Errorf("refund quantity cannot exceed original quantity")
}
refundAmount := float64(refundQuantity) * orderItem.UnitPrice
refundAmount := entities.RoundMoney(orderItem.BillableQuantityFor(refundQuantity) * orderItem.UnitPrice)
if itemRefund.RefundAmount != nil {
refundAmount = *itemRefund.RefundAmount
}
@@ -750,6 +874,7 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
}
}
p.onOrderRefunded(ctx, id)
return nil
}
@@ -781,6 +906,8 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
if err != nil {
return nil, err
}
// Not from updateOrderStatus: that runs inside the payment's transaction.
p.onOrderPaid(ctx, req.OrderID)
paymentWithRelations, err := p.paymentRepo.GetByID(ctx, payment.ID)
if err != nil {
@@ -805,7 +932,11 @@ func (p *OrderProcessorImpl) RefundPayment(ctx context.Context, paymentID uuid.U
return fmt.Errorf("refund amount cannot exceed payment amount")
}
return p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment)
if err := p.RefundPaymentWithInventoryMovement(ctx, paymentID, refundAmount, reason, refundedBy, payment); err != nil {
return err
}
p.onOrderRefunded(ctx, payment.OrderID)
return nil
}
func (p *OrderProcessorImpl) CreatePaymentWithInventoryMovement(ctx context.Context, req *models.CreatePaymentRequest, order *entities.Order, totalPaid float64) (*entities.Payment, error) {
@@ -1016,7 +1147,7 @@ func (p *OrderProcessorImpl) createInventoryMovement(ctx context.Context, item *
PreviousQuantity: float64(updatedInventory.Quantity + item.Quantity), // Add back the quantity that was subtracted
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(item.Quantity) * item.UnitCost,
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
@@ -1170,6 +1301,10 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
if err != nil {
return nil, err
}
// Both split paths mark the order paid on the payment that settles it.
if order.PaymentStatus == entities.PaymentStatusCompleted {
p.onOrderPaid(ctx, order.ID)
}
return response, nil
}
@@ -1314,7 +1449,7 @@ func (p *OrderProcessorImpl) prepareProductInventoryMovement(item *entities.Orde
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(item.Quantity) * item.UnitCost,
TotalCost: entities.RoundMoney(item.BillableQuantity() * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypePayment
return &t
@@ -1380,7 +1515,7 @@ type ingredientRecipeItem struct {
// prepareIngredientRecipeItem prepares data for a single ingredient recipe without making database calls
func (p *OrderProcessorImpl) prepareIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment) (*ingredientRecipeItem, error) {
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
if err != nil {
@@ -1478,7 +1613,7 @@ func (p *OrderProcessorImpl) prepareRefundProductInventoryMovement(item *entitie
PreviousQuantity: float64(previousQuantity),
NewQuantity: float64(updatedInventory.Quantity),
UnitCost: item.UnitCost,
TotalCost: float64(refundedQuantity) * item.UnitCost,
TotalCost: entities.RoundMoney(item.BillableQuantityFor(refundedQuantity) * item.UnitCost),
ReferenceType: func() *entities.InventoryMovementReferenceType {
t := entities.InventoryMovementReferenceTypeRefund
return &t
@@ -1544,7 +1679,7 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeData(ctx context.Con
// prepareRefundedIngredientRecipeItem prepares data for a single ingredient recipe restoration
func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Context, recipe *entities.ProductRecipe, item *entities.OrderItem, order *entities.Order, payment *entities.Payment, refundRatio float64, reason string, refundedBy uuid.UUID, refundAmount float64) (*ingredientRecipeItem, error) {
// Calculate total ingredient quantity needed based on order item quantity
totalIngredientQuantity := recipe.Quantity * float64(item.Quantity)
totalIngredientQuantity := recipe.Quantity * item.BillableQuantity()
// Get current ingredient details
currentIngredient, err := p.ingredientRepo.GetByID(ctx, recipe.IngredientID, order.OrganizationID)
+106
View File
@@ -0,0 +1,106 @@
package processor
import (
"testing"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
func fPtr(v float64) *float64 { return &v }
func TestResolveLineQuantity(t *testing.T) {
weightProduct := &entities.Product{Name: "Ikan Tude", SellBy: constants.SellByWeight}
unitProduct := &entities.Product{Name: "Nasi Goreng", SellBy: constants.SellByUnit}
tests := []struct {
name string
product *entities.Product
quantity int
weight *float64
wantQuantity int
wantWeight *float64
wantErr bool
}{
{
name: "weighed line is pinned to quantity 1",
// Mirrors chk_order_items_weight_single_line: even if a client sends 3,
// one weighing is one line.
product: weightProduct, quantity: 3, weight: fPtr(4.2),
wantQuantity: 1, wantWeight: fPtr(4.2),
},
{
name: "weight is rounded to stored precision",
product: weightProduct, quantity: 1, weight: fPtr(5.6004),
wantQuantity: 1, wantWeight: fPtr(5.6),
},
{
name: "weight product without a weight is rejected",
product: weightProduct, quantity: 1, weight: nil,
wantErr: true,
},
{
name: "weight that rounds away to zero is rejected",
product: weightProduct, quantity: 1, weight: fPtr(0.0004),
wantErr: true,
},
{
name: "unit line keeps its quantity and carries no weight",
product: unitProduct, quantity: 3, weight: nil,
wantQuantity: 3, wantWeight: nil,
},
{
name: "weight on a unit product is rejected",
// Otherwise the line would silently be priced by weight.
product: unitProduct, quantity: 1, weight: fPtr(4.2),
wantErr: true,
},
{
name: "non-positive quantity on a unit product is rejected",
product: unitProduct, quantity: 0, weight: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotQty, gotWeight, err := resolveLineQuantity(tt.product, tt.quantity, tt.weight)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got quantity=%v weight=%v", gotQty, gotWeight)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotQty != tt.wantQuantity {
t.Errorf("quantity = %v, want %v", gotQty, tt.wantQuantity)
}
switch {
case tt.wantWeight == nil && gotWeight != nil:
t.Errorf("weight = %v, want nil", *gotWeight)
case tt.wantWeight != nil && gotWeight == nil:
t.Errorf("weight = nil, want %v", *tt.wantWeight)
case tt.wantWeight != nil && *gotWeight != *tt.wantWeight:
t.Errorf("weight = %v, want %v", *gotWeight, *tt.wantWeight)
}
})
}
}
// A product created before this feature has sell_by defaulted to 'unit' by the
// migration, but a zero-valued struct must behave the same way.
func TestResolveLineQuantityTreatsEmptySellByAsUnit(t *testing.T) {
legacy := &entities.Product{Name: "Legacy"}
quantity, weight, err := resolveLineQuantity(legacy, 2, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if quantity != 2 || weight != nil {
t.Errorf("got quantity=%v weight=%v, want quantity=2 weight=nil", quantity, weight)
}
}
+13
View File
@@ -18,6 +18,8 @@ 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
@@ -133,6 +135,10 @@ func (p *otpProcessor) SendOtpViaWhatsApp(phoneNumber string, otpCode string, pu
switch purpose {
case "login":
message = fmt.Sprintf("Kode OTP untuk login kamu adalah %s. Berlaku 5 menit.", otpCode)
case "pin_setup":
message = fmt.Sprintf("Kode OTP untuk membuat PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun, termasuk kasir.", otpCode)
case "pin_reset":
message = fmt.Sprintf("Kode OTP untuk reset PIN EnakPoint kamu adalah %s. Berlaku 5 menit. Jangan berikan kode ini kepada siapa pun. Setelah reset, transfer ditahan 24 jam.", otpCode)
case "registration":
message = fmt.Sprintf("Kode OTP untuk registrasi kamu adalah %s. Berlaku 5 menit.", otpCode)
default:
@@ -236,3 +242,10 @@ 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
}
@@ -0,0 +1,101 @@
package processor
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
paymentCodeDigits = 6
paymentCodeTTL = 2 * time.Minute
paymentCodeAttempts = 5
// PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code.
PaymentCodeQRPrefix = "enakpoint:"
)
// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used,
// or belongs to another customer.
var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired")
type pinVerifier interface {
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
}
// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a
// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with
// their PIN on their own phone and shows the code; the PIN is never typed on the
// cashier's device.
type PaymentCodeProcessor struct {
codes repository.PaymentCodeRepository
pins pinVerifier
now func() time.Time
}
func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor {
return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now}
}
// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two
// minutes and bound to the customer. A new code retires the previous one.
func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) {
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil {
return nil, err
}
for attempt := 0; attempt < paymentCodeAttempts; attempt++ {
code, err := randomDigits(paymentCodeDigits)
if err != nil {
return nil, err
}
err = p.codes.Save(ctx, code, customerID, paymentCodeTTL)
if errors.Is(err, repository.ErrPaymentCodeTaken) {
continue
}
if err != nil {
return nil, err
}
return &models.PaymentCode{
Code: code,
QRPayload: PaymentCodeQRPrefix + code,
ExpiresAt: p.now().Add(paymentCodeTTL),
}, nil
}
return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts)
}
// Redeem uses a code up for a payment by the given customer. It accepts the code as
// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid.
func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix)
if len(code) != paymentCodeDigits {
return ErrPaymentCodeInvalid
}
err := p.codes.Consume(ctx, code, customerID)
if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) {
return ErrPaymentCodeInvalid
}
return err
}
// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be
// predicted.
func randomDigits(n int) (string, error) {
var b strings.Builder
for i := 0; i < n; i++ {
d, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", fmt.Errorf("failed to draw a payment code: %w", err)
}
b.WriteByte(byte('0' + d.Int64()))
}
return b.String(), nil
}
@@ -0,0 +1,136 @@
package processor
import (
"context"
"sync"
"sync/atomic"
"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"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type pinVerifierFake struct{ good string }
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
if action != PinActionPay {
return &PinError{Code: "UNEXPECTED_ACTION"}
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { client.Close() })
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
}
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
p, mr := newPaymentCodeTest(t)
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
}
func TestPaymentCode_Lifecycle(t *testing.T) {
p, mr := newPaymentCodeTest(t)
ctx := context.Background()
customer, other := uuid.New(), uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Len(t, code.Code, 6)
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
// A code of another customer is refused, and stays usable by its owner.
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
// Scanned from the QR it works; used once, it is gone.
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
// An expired code is refused.
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
mr.FastForward(2*time.Minute + time.Second)
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
// A new code retires the previous one.
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
if first.Code != second.Code {
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
}
require.NoError(t, p.Redeem(ctx, second.Code, customer))
// Garbage is refused without touching Redis.
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
}
}
// Two cashiers scanning the same code at once: exactly one gets it.
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
p, _ := newPaymentCodeTest(t)
ctx := context.Background()
customer := uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
var wins int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if p.Redeem(ctx, code.Code, customer) == nil {
atomic.AddInt32(&wins, 1)
}
}()
}
wg.Wait()
assert.Equal(t, int32(1), wins)
}
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer client.Close()
repo := repository.NewPaymentCodeRepository(client)
ctx := context.Background()
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
"a live code is never handed to a second customer")
}
func TestRandomDigits(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 200; i++ {
d, err := randomDigits(6)
require.NoError(t, err)
require.Len(t, d, 6)
for _, r := range d {
require.True(t, r >= '0' && r <= '9')
}
seen[d] = true
}
assert.Greater(t, len(seen), 190, "codes do not repeat")
}
+37 -2
View File
@@ -1,7 +1,10 @@
package processor
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"context"
"errors"
"fmt"
"apskel-pos-be/internal/mappers"
@@ -20,17 +23,26 @@ type PaymentMethodProcessor interface {
GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error)
}
// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint
// method, which the system owns (docs/prd-point-coin.md F9).
var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type")
type PaymentMethodProcessorImpl struct {
paymentMethodRepo repository.PaymentMethodRepository
outletSettings outletSettingsReader
}
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl {
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl {
return &PaymentMethodProcessorImpl{
paymentMethodRepo: paymentMethodRepo,
outletSettings: outletSettings,
}
}
func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) {
if req.Type == constants.PaymentMethodTypePoint {
return nil, ErrSystemPaymentMethod
}
exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
if err != nil {
return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err)
@@ -77,6 +89,17 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req
if req.Search != "" {
filters["search"] = req.Search
}
// At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in
// the query so paging stays right.
if req.OutletID != nil && p.outletSettings != nil {
settings, err := p.outletSettings.Outlet(ctx, *req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err)
}
if !settings.PointPayment.AcceptPayment {
filters["exclude_type"] = string(constants.PaymentMethodTypePoint)
}
}
offset := (req.Page - 1) * req.Limit
@@ -116,6 +139,15 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
return nil, fmt.Errorf("payment method not found: %w", err)
}
// The EnakPoint method keeps its type, and no other method can become one.
if req.Type != nil {
wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint
isPoint := *req.Type == constants.PaymentMethodTypePoint
if wasPoint != isPoint {
return nil, ErrSystemPaymentMethod
}
}
// Check name uniqueness if name is being updated
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id)
@@ -147,10 +179,13 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error {
// Check if payment method exists
_, err := p.paymentMethodRepo.GetByID(ctx, id)
existing, err := p.paymentMethodRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("payment method not found: %w", err)
}
if existing.Type == entities.PaymentMethodTypePoint {
return ErrSystemPaymentMethod
}
// TODO: Check if payment method is being used in any payments
// For now, allow deletion
@@ -0,0 +1,118 @@
package processor
import (
"context"
"os"
"testing"
"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"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestPointPaymentMethod_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New()
exec := func(q string, args ...any) error { return db.Exec(q, args...).Error }
require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org))
require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org))
t.Cleanup(func() {
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing})
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing})
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// A new organization gets exactly one EnakPoint method from the trigger.
var methods []struct {
ID string
Name string
Type string
}
require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error)
require.Len(t, methods, 1)
assert.Equal(t, "EnakPoint", methods[0].Name)
assert.Equal(t, "point", methods[0].Type)
pointID := uuid.MustParse(methods[0].ID)
// The database refuses a second one.
assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org))
txm := repository.NewTxManager(db)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
s, err := settings.Outlet(ctx, accepting)
require.NoError(t, err)
s.PointPayment.AcceptPayment = true
_, err = settings.UpdateOutlet(ctx, org, accepting, user, *s)
require.NoError(t, err)
p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings)
cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)})
require.NoError(t, err)
// The API cannot make, retype or delete an EnakPoint method.
_, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
toCash := constants.PaymentMethodTypeCash
_, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
toPoint := constants.PaymentMethodTypePoint
_, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod)
// Renaming it is fine; its type stays.
name := "Bayar pakai EnakPoint"
renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint})
require.NoError(t, err)
assert.Equal(t, name, renamed.Name)
// At the cashier it shows only where the outlet accepts EnakPoint.
types := func(outlet *uuid.UUID) []constants.PaymentMethodType {
t.Helper()
list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50})
require.NoError(t, err)
var out []constants.PaymentMethodType
for _, m := range list.PaymentMethods {
out = append(out, m.Type)
}
assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed")
return out
}
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting))
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing))
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it")
// A payment either records both points_used and point_value, or neither.
var orderID uuid.UUID
require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t"))
orderID = uuid.New()
require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount)
VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8]))
t.Cleanup(func() {
db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID)
db.Exec(`DELETE FROM orders WHERE id = ?`, orderID)
db.Exec(`DELETE FROM users WHERE id = ?`, user)
})
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID))
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID))
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID))
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID))
}
+8
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
@@ -191,6 +192,13 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
mappers.UpdateProductEntityFromRequest(existingProduct, req)
// Checked after the merge, not on the request: switching a product to sell_by
// "weight" is valid when it already carries a unit, and clearing the unit is
// invalid when it is already sold by weight. Only the merged product shows either.
if existingProduct.SellBy == constants.SellByWeight && existingProduct.UnitID == nil {
return nil, fmt.Errorf("product '%s' is sold by weight and requires a unit_id", existingProduct.Name)
}
if err := p.productRepo.Update(ctx, existingProduct); err != nil {
return nil, fmt.Errorf("failed to update product: %w", err)
}
+3 -3
View File
@@ -140,7 +140,7 @@ func calculateSplitAmounts(req *models.SplitBillRequest, validation *SplitBillVa
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -186,7 +186,7 @@ func (p *SplitBillProcessorImpl) createSplitPayment(ctx context.Context, req *mo
func (p *SplitBillProcessorImpl) createPaymentOrderItems(ctx context.Context, splitPayment *entities.Payment, req *models.SplitBillRequest, validation *SplitBillValidation) error {
for _, item := range req.Items {
orderItem := validation.OrderItems[item.OrderItemID]
itemAmount := float64(item.Quantity) * orderItem.UnitPrice
itemAmount := entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice)
itemTaxAmount := itemAmount * validation.Outlet.TaxRate
totalItemAmount := itemAmount + itemTaxAmount
@@ -228,7 +228,7 @@ func (p *SplitBillProcessorImpl) updateOrderAfterSplit(ctx context.Context, orde
quantityInfo[item.OrderItemID.String()] = map[string]interface{}{
"quantity": item.Quantity,
"unit_price": orderItem.UnitPrice,
"total_amount": float64(item.Quantity) * orderItem.UnitPrice,
"total_amount": entities.RoundMoney(orderItem.BillableQuantityFor(item.Quantity) * orderItem.UnitPrice),
}
}
order.Metadata[MetadataKeyLastSplitQuantities] = quantityInfo
@@ -0,0 +1,225 @@
package processor
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// walletDescriptionLimit is the size of wallet_transactions.description.
const walletDescriptionLimit = 255
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
type WalletAdminProcessor struct {
admin repository.WalletAdminRepository
query repository.WalletQueryRepository
wallet *WalletProcessor
tx TxRunner
now func() time.Time
}
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
}
// GetCustomerWallet returns the customer's balances, lots and a page of history.
// A customer outside the organization is reported as not found.
func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizationID, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.AdminCustomerWallet, error) {
filter, page, err := walletTransactionFilter(customerID, query)
if err != nil {
return nil, err
}
customer, err := p.admin.GetCustomer(ctx, organizationID, customerID)
if err != nil {
return nil, err
}
now := p.now()
result := &models.AdminCustomerWallet{
Customer: models.AdminWalletCustomer{ID: customer.ID, Name: customer.Name, Phone: customer.Phone},
Lots: []models.AdminWalletLot{},
}
wallet, err := p.admin.GetWallet(ctx, customerID)
if err != nil {
return nil, err
}
if wallet != nil {
result.PointBalance = wallet.PointBalance
result.CoinBalance = wallet.CoinBalance
}
spendable, err := p.query.SpendableBalances(ctx, customerID, now)
if err != nil {
return nil, err
}
result.SpendablePointBalance = spendable[constants.WalletCurrencyPoint]
result.SpendableCoinBalance = spendable[constants.WalletCurrencyCoin]
lots, err := p.admin.ListLotsWithBalance(ctx, customerID)
if err != nil {
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,
})
}
rows, total, err := p.query.ListTransactions(ctx, filter)
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, rows)
if err != nil {
return nil, err
}
result.Transactions = models.PaginatedResponse[models.AdminWalletTransaction]{
Data: transactions,
Pagination: models.Pagination{
Page: page,
Limit: filter.Limit,
Total: total,
TotalPages: int((total + int64(filter.Limit) - 1) / int64(filter.Limit)),
},
}
return result, nil
}
// Adjust adds to or takes from a customer's balance by hand. It is recorded as an
// ADJUSTMENT pointing at the admin, with the reason. Taking more than the customer
// can spend is refused with repository.ErrWalletInsufficientBalance.
func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, adminID, customerID uuid.UUID, in models.WalletAdjustment) (*models.AdminWalletAdjustmentResult, error) {
reason := strings.TrimSpace(in.Reason)
if adminID == uuid.Nil {
return nil, fmt.Errorf("%w: the admin making the adjustment is unknown", ErrWalletInvalidEntry)
}
if in.Amount == 0 {
return nil, fmt.Errorf("%w: amount must not be zero", ErrWalletInvalidEntry)
}
entry := WalletEntry{
CustomerID: customerID,
Currency: in.Currency,
Type: constants.WalletTxTypeAdjustment,
Amount: abs(in.Amount),
ReferenceType: constants.WalletRefTypeUser,
ReferenceID: adminID,
CreatedByUser: &adminID,
Reason: &reason,
Description: truncateRunes("Koreksi oleh admin: "+reason, walletDescriptionLimit),
}
if in.IdempotencyKey != "" {
// Scoped to the customer so a key chosen by the dashboard cannot collide with
// the keys other flows use.
entry.IdempotencyKey = fmt.Sprintf("adjust:%s:%s", customerID, in.IdempotencyKey)
}
var res *WalletResult
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
return err
}
var err error
if in.Amount > 0 {
// Adjustments never expire until expiry is decided (F12, note N4).
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
} else {
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
}
return err
})
if err != nil {
return nil, err
}
transactions, err := p.adminTransactions(ctx, []entities.WalletTransaction{*res.Transaction})
if err != nil {
return nil, err
}
spendable, err := p.query.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return &models.AdminWalletAdjustmentResult{
Transaction: transactions[0],
SpendablePointBalance: spendable[constants.WalletCurrencyPoint],
SpendableCoinBalance: spendable[constants.WalletCurrencyCoin],
Replayed: res.Replayed,
}, nil
}
// adminTransactions adds, to what the customer sees, the names behind each row.
func (p *WalletAdminProcessor) adminTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.AdminWalletTransaction, error) {
base, err := buildWalletTransactions(ctx, p.query, rows)
if err != nil {
return nil, err
}
var customerIDs, userIDs, outletIDs []uuid.UUID
for _, row := range rows {
if row.CounterpartyCustomerID != nil {
customerIDs = append(customerIDs, *row.CounterpartyCustomerID)
}
if row.CreatedByUser != nil {
userIDs = append(userIDs, *row.CreatedByUser)
}
if row.OutletID != nil {
outletIDs = append(outletIDs, *row.OutletID)
}
}
customers, err := p.admin.CustomerNames(ctx, customerIDs)
if err != nil {
return nil, err
}
users, err := p.admin.UserNames(ctx, userIDs)
if err != nil {
return nil, err
}
outlets, err := p.admin.OutletNames(ctx, outletIDs)
if err != nil {
return nil, err
}
named := func(id *uuid.UUID, names map[uuid.UUID]string) *models.AdminWalletNamedRef {
if id == nil {
return nil
}
return &models.AdminWalletNamedRef{ID: *id, Name: names[*id]}
}
out := make([]models.AdminWalletTransaction, 0, len(rows))
for i, row := range rows {
out = append(out, models.AdminWalletTransaction{
CustomerWalletTransaction: base[i],
Counterparty: named(row.CounterpartyCustomerID, customers),
CreatedBy: named(row.CreatedByUser, users),
Outlet: named(row.OutletID, outlets),
Reason: row.Reason,
Metadata: row.Metadata,
})
}
return out, nil
}
func truncateRunes(s string, limit int) string {
if utf8.RuneCountInString(s) <= limit {
return s
}
return string([]rune(s)[:limit])
}
@@ -0,0 +1,184 @@
package processor
import (
"context"
"fmt"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
// TxRunner runs fn inside a database transaction. repository.TxManager is one.
type TxRunner interface {
WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}
// WalletMigrationDiscrepancy is a customer whose legacy balance is now lower than
// what was already migrated: the old code spent from it after the migration ran.
// The wallet is left alone, because only an admin adjustment can take balance away.
type WalletMigrationDiscrepancy struct {
CustomerID uuid.UUID
Currency string
Legacy int64
Migrated int64
}
type WalletMigrationReport struct {
DryRun bool
CustomersScanned int
// Ledger rows written (or, on a dry run, that would be written) and their sum.
PointCredits int
PointsCredited int64
CoinCredits int
CoinsCredited int64
Discrepancies []WalletMigrationDiscrepancy
// Taken after the run. On a dry run they show the state before it.
Totals *repository.WalletMigrationTotals
}
// Balanced reports whether everything in the legacy tables is now in the wallet.
func (r *WalletMigrationReport) Balanced() bool {
return len(r.Discrepancies) == 0 && r.Totals != nil &&
r.Totals.LegacyPoints == r.Totals.MigratedPoints &&
r.Totals.LegacyCoins == r.Totals.MigratedCoins
}
// WalletMigrationProcessor moves the balances in customer_points and customer_tokens
// into the wallet (docs/prd-point-coin.md §10, PC-105). Each customer gets a MIGRATION
// ledger row and a non-expiring lot per currency, through WalletProcessor like any
// other credit, so the wallet reconciles from the first row.
//
// It credits the difference between the legacy balance and what earlier runs already
// migrated, so running it again never doubles a balance, and a run after the old code
// kept writing to the legacy tables picks up only what was added since.
type WalletMigrationProcessor struct {
repo repository.WalletMigrationRepository
wallet *WalletProcessor
tx TxRunner
}
func NewWalletMigrationProcessor(repo repository.WalletMigrationRepository, wallet *WalletProcessor, tx TxRunner) *WalletMigrationProcessor {
return &WalletMigrationProcessor{repo: repo, wallet: wallet, tx: tx}
}
// Run migrates every customer with a legacy balance, one transaction per customer.
// With dryRun it only reports what it would credit.
func (p *WalletMigrationProcessor) Run(ctx context.Context, dryRun bool, batchSize int) (*WalletMigrationReport, error) {
if batchSize <= 0 {
batchSize = 500
}
report := &WalletMigrationReport{DryRun: dryRun}
after := uuid.Nil
for {
ids, err := p.repo.ListLegacyCustomers(ctx, after, batchSize)
if err != nil {
return nil, err
}
if len(ids) == 0 {
break
}
for _, id := range ids {
if dryRun {
err = p.migrateCustomer(ctx, id, true, report)
} else {
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
return p.migrateCustomer(ctx, id, false, report)
})
}
if err != nil {
return nil, fmt.Errorf("customer %s: %w", id, err)
}
report.CustomersScanned++
}
after = ids[len(ids)-1]
}
totals, err := p.repo.Totals(ctx)
if err != nil {
return nil, err
}
report.Totals = totals
return report, nil
}
func (p *WalletMigrationProcessor) migrateCustomer(ctx context.Context, customerID uuid.UUID, dryRun bool, report *WalletMigrationReport) error {
// Lock before reading what was migrated, so two runs at once cannot both see the
// same gap and fill it twice.
if !dryRun {
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
return err
}
}
legacy, err := p.repo.GetLegacyBalance(ctx, customerID)
if err != nil {
return err
}
// Points come from the single customer_points row. Tokens come from several rows,
// one per type, so the ledger row points at the customer and lists the rows.
pointsRef := customerID
if legacy.PointsRowID != nil {
pointsRef = *legacy.PointsRowID
}
tokens := make([]map[string]any, 0, len(legacy.Tokens))
for _, t := range legacy.Tokens {
tokens = append(tokens, map[string]any{"id": t.ID, "token_type": string(t.TokenType), "balance": t.Balance})
}
for _, c := range []struct {
currency, refType string
refID uuid.UUID
legacy int64
metadata entities.Metadata
credits *int
credited *int64
}{
{constants.WalletCurrencyPoint, constants.WalletRefTypeLegacyPoints, pointsRef, legacy.Points,
entities.Metadata{}, &report.PointCredits, &report.PointsCredited},
{constants.WalletCurrencyCoin, constants.WalletRefTypeLegacyTokens, customerID, legacy.Coins(),
entities.Metadata{"legacy_tokens": tokens}, &report.CoinCredits, &report.CoinsCredited},
} {
migrated, err := p.repo.SumMigrated(ctx, customerID, c.currency)
if err != nil {
return err
}
delta := c.legacy - migrated
if delta < 0 {
report.Discrepancies = append(report.Discrepancies, WalletMigrationDiscrepancy{
CustomerID: customerID, Currency: c.currency, Legacy: c.legacy, Migrated: migrated,
})
continue
}
if delta == 0 {
continue
}
if !dryRun {
c.metadata["legacy_balance"] = c.legacy
c.metadata["previously_migrated"] = migrated
_, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: c.currency,
Type: constants.WalletTxTypeMigration,
Amount: delta,
ReferenceType: c.refType,
ReferenceID: c.refID,
Description: "Saldo awal dari sistem lama",
Metadata: c.metadata,
// The legacy total in the key lets a later run top up a balance that
// grew, while a retry of the same run is still recognised.
IdempotencyKey: fmt.Sprintf("migration:%s:%s:%d", c.currency, customerID, c.legacy),
}})
if err != nil {
return err
}
}
*c.credits++
*c.credited += delta
}
return nil
}
@@ -0,0 +1,167 @@
package processor
import (
"context"
"os"
"testing"
"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/repository"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go. Other packages' tests may use the
// same database at the same time, so everything here is scoped to its own customers.
func TestWalletMigrationProcessor_AgainstPostgres(t *testing.T) {
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)
ctx := context.Background()
org := uuid.New()
full, tokensOnly, pointsOnly, none := uuid.New(), uuid.New(), uuid.New(), uuid.New()
customers := []uuid.UUID{full, tokensOnly, pointsOnly, none}
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'migration test', 'basic')`, org)
for _, c := range customers {
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'migration test')`, c, org)
}
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 ?`, 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)
})
// The example from §10: SPIN 5 + RAFFLE 2 + MINIGAME 1 = 8 EnakCoin.
exec(`INSERT INTO customer_points (customer_id, balance) VALUES (?, 100), (?, 0), (?, 40)`, full, tokensOnly, pointsOnly)
exec(`INSERT INTO customer_tokens (customer_id, token_type, balance) VALUES
(?, 'SPIN', 5), (?, 'RAFFLE', 2), (?, 'MINIGAME', 1), (?, 'SPIN', 3)`, full, full, full, tokensOnly)
migrator := NewWalletMigrationProcessor(
repository.NewWalletMigrationRepository(db),
NewWalletProcessor(repository.NewWalletRepository(db)),
repository.NewTxManager(db),
)
type balance struct{ Point, Coin int64 }
balances := func() map[uuid.UUID]balance {
t.Helper()
var rows []struct {
CustomerID uuid.UUID
PointBalance, CoinBalance int64
}
require.NoError(t, db.Raw(`SELECT customer_id, point_balance, coin_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&rows).Error)
out := map[uuid.UUID]balance{}
for _, r := range rows {
out[r.CustomerID] = balance{r.PointBalance, r.CoinBalance}
}
return out
}
countRows := func() int64 {
t.Helper()
var n int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ?`, customers).Scan(&n).Error)
return n
}
// A dry run reports and writes nothing, not even the wallets.
report, err := migrator.Run(ctx, true, 2)
require.NoError(t, err)
assert.GreaterOrEqual(t, report.PointsCredited, int64(140))
assert.GreaterOrEqual(t, report.CoinsCredited, int64(11))
assert.Empty(t, balances())
assert.Zero(t, countRows())
// The real run. A batch of 2 makes it page through the customers.
_, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, map[uuid.UUID]balance{
full: {Point: 100, Coin: 8},
tokensOnly: {Point: 0, Coin: 3},
pointsOnly: {Point: 40, Coin: 0},
}, balances(), "a customer without legacy rows gets no wallet")
assert.Equal(t, int64(4), countRows(), "one row per customer per currency with a balance")
var coinRow struct {
ReferenceType string
ReferenceID uuid.UUID
Metadata string
}
require.NoError(t, db.Raw(`SELECT reference_type, reference_id, metadata::text AS metadata FROM wallet_transactions
WHERE customer_id = ? AND currency = 'COIN'`, full).Scan(&coinRow).Error)
assert.Equal(t, constants.WalletRefTypeLegacyTokens, coinRow.ReferenceType)
assert.Equal(t, full, coinRow.ReferenceID)
for _, part := range []string{`"token_type": "SPIN"`, `"token_type": "RAFFLE"`, `"token_type": "MINIGAME"`, `"legacy_balance": 8`} {
assert.Contains(t, coinRow.Metadata, part)
}
var pointRef, pointsRowID string
require.NoError(t, db.Raw(`SELECT reference_id::text FROM wallet_transactions WHERE customer_id = ? AND currency = 'POINT'`, full).Scan(&pointRef).Error)
require.NoError(t, db.Raw(`SELECT id::text FROM customer_points WHERE customer_id = ?`, full).Scan(&pointsRowID).Error)
assert.NotEmpty(t, pointRef)
assert.Equal(t, pointsRowID, pointRef, "points row points at the customer_points row")
var expiring int64
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_lots WHERE customer_id IN ? AND expires_at IS NOT NULL`, customers).Scan(&expiring).Error)
assert.Zero(t, expiring, "migrated lots never expire")
// Running again changes nothing.
report, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, int64(4), countRows())
assert.Empty(t, discrepanciesFor(report, customers))
// The old code kept writing: one balance grew, one shrank. Only the growth is
// migrated; the shrink is reported and left alone.
exec(`UPDATE customer_tokens SET balance = 9 WHERE customer_id = ? AND token_type = 'SPIN'`, full)
exec(`UPDATE customer_points SET balance = 30 WHERE customer_id = ?`, pointsOnly)
report, err = migrator.Run(ctx, false, 2)
require.NoError(t, err)
assert.Equal(t, balance{Point: 100, Coin: 12}, balances()[full])
assert.Equal(t, balance{Point: 40, Coin: 0}, balances()[pointsOnly])
assert.Equal(t, []WalletMigrationDiscrepancy{{CustomerID: pointsOnly, Currency: constants.WalletCurrencyPoint, Legacy: 30, Migrated: 40}},
discrepanciesFor(report, customers))
assert.Equal(t, int64(5), countRows())
// §7.5 for these customers.
var broken int64
require.NoError(t, db.Raw(`
SELECT COUNT(*) FROM customer_wallets w
WHERE w.customer_id IN ? AND (
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
OR w.coin_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'COIN')
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT')
OR w.coin_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'COIN'))`,
customers).Scan(&broken).Error)
assert.Zero(t, broken)
}
func discrepanciesFor(report *WalletMigrationReport, customers []uuid.UUID) []WalletMigrationDiscrepancy {
mine := map[uuid.UUID]bool{}
for _, c := range customers {
mine[c] = true
}
var out []WalletMigrationDiscrepancy
for _, d := range report.Discrepancies {
if mine[d.CustomerID] {
out = append(out, d)
}
}
return out
}
+527
View File
@@ -0,0 +1,527 @@
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/repository"
)
var (
// ErrWalletInvalidEntry wraps every rejection of an entry that breaks the rules in
// docs/prd-point-coin.md §8.1. The database enforces most of them too; checking
// here first gives callers a readable error instead of a constraint name.
ErrWalletInvalidEntry = errors.New("wallet: invalid entry")
// ErrWalletIdempotencyConflict means an idempotency key was reused for a different
// operation. Retrying the same operation with the same key is not a conflict.
ErrWalletIdempotencyConflict = errors.New("wallet: idempotency key already used for a different operation")
)
// WalletEntry is what every ledger row needs, whichever way it moves the balance.
// Which of the optional fields a type requires is listed in §8.1.
type WalletEntry struct {
// Optional. Set it when another row must reference this one before it exists, as
// the two rows of an exchange or a transfer do.
TransactionID uuid.UUID
CustomerID uuid.UUID
Currency string
Type string
// Always positive: Credit adds it, Debit takes it away.
Amount int64
ReferenceType string
ReferenceID uuid.UUID
GroupID *uuid.UUID
CounterpartyCustomerID *uuid.UUID
ReversesTransactionID *uuid.UUID
OutletID *uuid.UUID
CreatedByUser *uuid.UUID
Reason *string
Description string
Metadata entities.Metadata
// Optional. A retry with the same key returns the first result without moving
// anything again.
IdempotencyKey string
}
// WalletLotInput is one lot a credit creates.
type WalletLotInput struct {
Amount int64
// Nil means the lot never expires.
ExpiresAt *time.Time
// The lot this one was carried over from, for transfers, exchanges and refunds.
OriginLotID *uuid.UUID
}
type WalletCreditInput struct {
WalletEntry
// How the credit is split into lots. Their amounts must add up to Amount. Leave
// empty for a single lot that never expires.
Lots []WalletLotInput
}
type WalletDebitInput struct {
WalletEntry
// Lots to draw from first, in this order, before falling back to the K9 order.
// A reversal names the lots its EARN created (F10), and the expiry job names the
// lot that expired. These lots are used even if they have already expired.
PreferredLotIDs []uuid.UUID
}
// WalletAllocation is how much a debit took from one lot. It carries the lot's
// expiry, so a transfer or exchange can give the receiving lot the same expiry (K9).
type WalletAllocation struct {
LotID uuid.UUID
Amount int64
ExpiresAt *time.Time
}
type WalletResult struct {
// Nil only when DebitUpTo found nothing to take.
Transaction *entities.WalletTransaction
// The lots a credit created.
Lots []entities.WalletLot
// The lots a debit drew from, in the order they were used.
Allocations []WalletAllocation
// What DebitUpTo could not take because the balance ran out.
Shortfall int64
// True when the idempotency key had already been used and nothing moved.
Replayed bool
}
// CarryOver turns a debit's allocations into lots for the receiving side of a
// transfer or exchange. Each lot keeps the expiry of the lot it came from and points
// back at it, so a balance cannot be kept alive by moving it around (K9).
func (r *WalletResult) CarryOver() []WalletLotInput {
lots := make([]WalletLotInput, 0, len(r.Allocations))
for _, a := range r.Allocations {
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: a.Amount, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
}
return lots
}
// WalletProcessor is the only code allowed to change a wallet balance. Every change
// writes the balance, the ledger row and the lots or allocations together, which is
// what keeps SUM(ledger) = balance = SUM(lot remaining) (§7.5).
//
// Every method must run inside a transaction from TxManager, and the repository
// refuses otherwise. Each method locks the customer's wallet itself, so a single-wallet
// caller needs nothing more. A caller touching two wallets, such as a transfer, must
// call LockWallets first so the locks are always taken in the same order.
type WalletProcessor struct {
repo repository.WalletRepository
now func() time.Time
}
func NewWalletProcessor(repo repository.WalletRepository) *WalletProcessor {
return &WalletProcessor{repo: repo, now: time.Now}
}
// LockWallet locks one customer's wallet, creating it if needed. Credit and Debit do
// this themselves; call it when something must be read under the lock first.
func (p *WalletProcessor) LockWallet(ctx context.Context, customerID uuid.UUID) error {
_, err := p.repo.LockWallet(ctx, customerID)
return err
}
// LockWallets locks two customers' wallets in a fixed order. Call it before touching
// both wallets in one transaction.
func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error {
_, _, err := p.repo.LockWallets(ctx, a, b)
return err
}
// 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 {
return nil, err
}
lots := in.Lots
if len(lots) == 0 {
lots = []WalletLotInput{{Amount: in.Amount}}
}
var total int64
for _, lot := range lots {
if lot.Amount <= 0 {
return nil, fmt.Errorf("%w: lot amount must be positive, got %d", ErrWalletInvalidEntry, lot.Amount)
}
total += lot.Amount
}
if total != in.Amount {
return nil, fmt.Errorf("%w: lots add up to %d, not %d", ErrWalletInvalidEntry, total, in.Amount)
}
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
if err != nil {
return nil, err
}
if replay, err := p.replay(ctx, &in.WalletEntry, true, true); replay != nil || err != nil {
return replay, err
}
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, in.Amount)
if err != nil {
return nil, err
}
walletTx := newWalletTransaction(wallet, &in.WalletEntry, in.Amount, balance, nil)
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
}
result := &WalletResult{Transaction: walletTx}
for _, lotIn := range lots {
lot := entities.WalletLot{
OrganizationID: wallet.OrganizationID,
CustomerID: in.CustomerID,
Currency: in.Currency,
SourceTransactionID: walletTx.ID,
OriginLotID: lotIn.OriginLotID,
OriginalAmount: lotIn.Amount,
RemainingAmount: lotIn.Amount,
ExpiresAt: lotIn.ExpiresAt,
}
if err := p.repo.CreateLot(ctx, &lot); err != nil {
return nil, fmt.Errorf("failed to create wallet lot: %w", err)
}
result.Lots = append(result.Lots, lot)
}
return result, nil
}
// Debit takes exactly Amount from the wallet, or nothing at all with
// repository.ErrWalletInsufficientBalance if the usable balance is short.
func (p *WalletProcessor) Debit(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
return p.debit(ctx, in, false)
}
// DebitUpTo takes as much of Amount as the wallet has and reports the rest as
// Shortfall. It is for reversing earnings the customer has already spent (F10, Q3).
// When there is nothing to take, no ledger row is written and Transaction is nil;
// such a call leaves no trace, so a retry with the same key takes whatever the
// balance holds by then.
func (p *WalletProcessor) DebitUpTo(ctx context.Context, in WalletDebitInput) (*WalletResult, error) {
return p.debit(ctx, in, true)
}
func (p *WalletProcessor) debit(ctx context.Context, in WalletDebitInput, upTo bool) (*WalletResult, error) {
if err := validateWalletEntry(&in.WalletEntry, false); err != nil {
return nil, err
}
wallet, err := p.repo.LockWallet(ctx, in.CustomerID)
if err != nil {
return nil, err
}
// DebitUpTo may have taken less than asked, so the amount cannot be compared.
if replay, err := p.replay(ctx, &in.WalletEntry, false, !upTo); replay != nil || err != nil {
return replay, err
}
lots, err := p.spendableLots(ctx, &in)
if err != nil {
return nil, err
}
var available int64
for _, lot := range lots {
available += lot.RemainingAmount
}
take := in.Amount
if available < take {
if !upTo {
return nil, repository.ErrWalletInsufficientBalance
}
take = available
}
result := &WalletResult{Shortfall: in.Amount - take}
if take == 0 {
return result, nil
}
var metadata entities.Metadata
if upTo {
metadata = entities.Metadata{"requested_amount": in.Amount, "shortfall": result.Shortfall}
}
balance, err := p.repo.AddBalance(ctx, in.CustomerID, in.Currency, -take)
if err != nil {
return nil, err
}
walletTx := newWalletTransaction(wallet, &in.WalletEntry, -take, balance, metadata)
if err := p.repo.CreateTransaction(ctx, walletTx); err != nil {
return nil, fmt.Errorf("failed to create wallet transaction: %w", err)
}
result.Transaction = walletTx
var allocations []entities.WalletLotAllocation
remaining := take
for _, lot := range lots {
if remaining == 0 {
break
}
amount := min(lot.RemainingAmount, remaining)
remaining -= amount
if err := p.repo.ConsumeLot(ctx, lot.ID, amount); err != nil {
return nil, err
}
allocations = append(allocations, entities.WalletLotAllocation{TransactionID: walletTx.ID, LotID: lot.ID, Amount: amount})
result.Allocations = append(result.Allocations, WalletAllocation{LotID: lot.ID, Amount: amount, ExpiresAt: lot.ExpiresAt})
}
if err := p.repo.CreateAllocations(ctx, allocations); err != nil {
return nil, fmt.Errorf("failed to create wallet lot allocations: %w", err)
}
return result, nil
}
// spendableLots returns the lots a debit may draw from, in the order it draws: the
// preferred lots first, then the unexpired lots in K9 order.
func (p *WalletProcessor) spendableLots(ctx context.Context, in *WalletDebitInput) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
preferred := make(map[uuid.UUID]bool, len(in.PreferredLotIDs))
if len(in.PreferredLotIDs) > 0 {
found, err := p.repo.GetLotsByIDs(ctx, in.PreferredLotIDs)
if err != nil {
return nil, err
}
byID := make(map[uuid.UUID]entities.WalletLot, len(found))
for _, lot := range found {
byID[lot.ID] = lot
}
for _, id := range in.PreferredLotIDs {
lot, ok := byID[id]
if !ok || lot.CustomerID != in.CustomerID || lot.Currency != in.Currency {
return nil, fmt.Errorf("%w: lot %s is not a %s lot of this customer", ErrWalletInvalidEntry, id, in.Currency)
}
if preferred[id] {
continue
}
preferred[id] = true
if lot.RemainingAmount > 0 {
lots = append(lots, lot)
}
}
}
active, err := p.repo.ListActiveLots(ctx, in.CustomerID, in.Currency, p.now())
if err != nil {
return nil, err
}
for _, lot := range active {
if !preferred[lot.ID] {
lots = append(lots, lot)
}
}
return lots, nil
}
// replay returns the first result for an idempotency key that has already been used,
// or nil when the key is new. It runs after the wallet lock, so a concurrent request
// with the same key has either committed its row or not started.
func (p *WalletProcessor) replay(ctx context.Context, in *WalletEntry, credit, compareAmount bool) (*WalletResult, error) {
if in.IdempotencyKey == "" {
return nil, nil
}
walletTx, err := p.repo.GetTransactionByIdempotencyKey(ctx, in.IdempotencyKey)
if err != nil || walletTx == nil {
return nil, err
}
sameDirection := (walletTx.Amount > 0) == credit
sameAmount := !compareAmount || abs(walletTx.Amount) == in.Amount
if walletTx.CustomerID != in.CustomerID || walletTx.Currency != in.Currency ||
walletTx.Type != in.Type || !sameDirection || !sameAmount {
return nil, ErrWalletIdempotencyConflict
}
result := &WalletResult{Transaction: walletTx, Replayed: true}
if credit {
result.Lots, err = p.repo.ListLotsBySourceTransaction(ctx, walletTx.ID)
return result, err
}
// JSON numbers come back from JSONB as float64.
switch shortfall := walletTx.Metadata["shortfall"].(type) {
case float64:
result.Shortfall = int64(shortfall)
case int64:
result.Shortfall = shortfall
}
allocations, err := p.repo.ListAllocationsByTransaction(ctx, walletTx.ID)
if err != nil {
return nil, err
}
ids := make([]uuid.UUID, 0, len(allocations))
for _, a := range allocations {
ids = append(ids, a.LotID)
}
lots, err := p.repo.GetLotsByIDs(ctx, ids)
if err != nil {
return nil, err
}
expiry := make(map[uuid.UUID]*time.Time, len(lots))
for _, lot := range lots {
expiry[lot.ID] = lot.ExpiresAt
}
for _, a := range allocations {
result.Allocations = append(result.Allocations, WalletAllocation{LotID: a.LotID, Amount: a.Amount, ExpiresAt: expiry[a.LotID]})
}
return result, nil
}
func newWalletTransaction(wallet *entities.CustomerWallet, in *WalletEntry, amount, balance int64, extra entities.Metadata) *entities.WalletTransaction {
metadata := entities.Metadata{}
for k, v := range in.Metadata {
metadata[k] = v
}
for k, v := range extra {
metadata[k] = v
}
var key *string
if in.IdempotencyKey != "" {
k := in.IdempotencyKey
key = &k
}
return &entities.WalletTransaction{
ID: in.TransactionID,
OrganizationID: wallet.OrganizationID,
CustomerID: in.CustomerID,
Currency: in.Currency,
Type: in.Type,
Amount: amount,
BalanceAfter: balance,
GroupID: in.GroupID,
ReferenceType: in.ReferenceType,
ReferenceID: in.ReferenceID,
CounterpartyCustomerID: in.CounterpartyCustomerID,
ReversesTransactionID: in.ReversesTransactionID,
OutletID: in.OutletID,
CreatedByUser: in.CreatedByUser,
Reason: in.Reason,
Description: in.Description,
Metadata: metadata,
IdempotencyKey: key,
}
}
// walletTypeRule is one row of §8.1.
type walletTypeRule struct {
credit, debit bool
currency string // empty: either currency
referenceTypes []string
needsOutlet bool
needsReverses bool
needsGroup bool
needsCounter bool
needsActor bool
}
var walletTypeRules = map[string]walletTypeRule{
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true},
constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
}
func validateWalletEntry(in *WalletEntry, credit bool) error {
invalid := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrWalletInvalidEntry, fmt.Sprintf(format, args...))
}
rule, ok := walletTypeRules[in.Type]
if !ok {
return invalid("unknown type %q", in.Type)
}
if credit && !rule.credit {
return invalid("%s cannot add to a balance", in.Type)
}
if !credit && !rule.debit {
return invalid("%s cannot take from a balance", in.Type)
}
if in.CustomerID == uuid.Nil {
return invalid("customer is required")
}
if !constants.IsValidWalletCurrency(in.Currency) {
return invalid("unknown currency %q", in.Currency)
}
if rule.currency != "" && in.Currency != rule.currency {
return invalid("%s must be in %s", in.Type, rule.currency)
}
if in.Amount <= 0 {
return invalid("amount must be positive, got %d", in.Amount)
}
if !containsString(rule.referenceTypes, in.ReferenceType) {
return invalid("%s must reference %s, got %q", in.Type, strings.Join(rule.referenceTypes, " or "), in.ReferenceType)
}
if in.ReferenceID == uuid.Nil {
return invalid("reference id is required")
}
if strings.TrimSpace(in.Description) == "" {
return invalid("description is required")
}
if rule.needsOutlet && isNilID(in.OutletID) {
return invalid("%s requires an outlet", in.Type)
}
if rule.needsReverses && isNilID(in.ReversesTransactionID) {
return invalid("%s requires the transaction it reverses", in.Type)
}
if rule.needsGroup && isNilID(in.GroupID) {
return invalid("%s requires a group id", in.Type)
}
if rule.needsCounter {
if isNilID(in.CounterpartyCustomerID) {
return invalid("%s requires a counterparty", in.Type)
}
if *in.CounterpartyCustomerID == in.CustomerID {
return invalid("%s cannot go to the same customer", in.Type)
}
}
if rule.needsActor {
if isNilID(in.CreatedByUser) {
return invalid("%s requires the admin who made it", in.Type)
}
if in.Reason == nil || strings.TrimSpace(*in.Reason) == "" {
return invalid("%s requires a reason", in.Type)
}
}
return nil
}
func isNilID(id *uuid.UUID) bool {
return id == nil || *id == uuid.Nil
}
func containsString(values []string, v string) bool {
for _, value := range values {
if value == v {
return true
}
}
return false
}
func abs(v int64) int64 {
if v < 0 {
return -v
}
return v
}
@@ -0,0 +1,142 @@
package processor
import (
"context"
"os"
"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/repository"
)
// Runs the engine against Postgres, to show the rows it writes pass the database
// constraints and reconcile the way §7.5 requires. Needs TEST_DATABASE_URL pointing
// at a migrated database; see internal/repository/wallet_repository_test.go.
func TestWalletProcessor_AgainstPostgres(t *testing.T) {
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()
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet test', 'basic')`, org).Error)
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, a, org, b, org).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 ?`, 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)
})
p := NewWalletProcessor(repository.NewWalletRepository(db))
txm := repository.NewTxManager(db)
now := time.Now()
inTx := func(fn func(ctx context.Context) error) {
t.Helper()
require.NoError(t, txm.WithTransaction(context.Background(), fn))
}
// Without a transaction nothing moves.
_, err = p.Credit(context.Background(), earn(a, 10, nil))
assert.ErrorIs(t, err, repository.ErrWalletTxRequired)
var earned *WalletResult
inTx(func(ctx context.Context) error {
soon := now.Add(time.Hour)
earned, err = p.Credit(ctx, earn(a, 100, &soon))
require.NoError(t, err)
_, err = p.Credit(ctx, earn(a, 50, nil))
return err
})
// Transfer 120 from A to B, spanning both of A's lots.
inTx(func(ctx context.Context) error {
require.NoError(t, p.LockWallets(ctx, a, b))
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
out, err := p.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferOut, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
}})
require.NoError(t, err)
_, err = p.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferIn, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
},
Lots: out.CarryOver(),
})
return err
})
// Reversing the 100 earned leaves A 30 short. The retry reads the shortfall back
// out of JSONB and takes nothing more.
rev := reversal(a, 100, earned)
rev.IdempotencyKey = "reverse:" + earned.Transaction.ID.String()
var first, second *WalletResult
inTx(func(ctx context.Context) error {
first, err = p.DebitUpTo(ctx, rev)
return err
})
inTx(func(ctx context.Context) error {
second, err = p.DebitUpTo(ctx, rev)
return err
})
assert.Equal(t, int64(-30), first.Transaction.Amount)
assert.Equal(t, int64(70), first.Shortfall)
assert.True(t, second.Replayed)
assert.Equal(t, int64(70), second.Shortfall)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
// Overdraw fails and rolls back cleanly.
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := p.Debit(ctx, pay(b, 121))
return err
})
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
var balances []struct {
CustomerID uuid.UUID
PointBalance int64
}
require.NoError(t, db.Raw(`SELECT customer_id, point_balance FROM customer_wallets WHERE customer_id IN ?`, customers).Scan(&balances).Error)
got := map[uuid.UUID]int64{}
for _, row := range balances {
got[row.CustomerID] = row.PointBalance
}
assert.Equal(t, map[uuid.UUID]int64{a: 0, b: 120}, got)
// §7.5, straight from the tables.
var broken []string
require.NoError(t, db.Raw(`
SELECT 'wallet ' || w.customer_id FROM customer_wallets w
WHERE w.customer_id IN ? AND (
w.point_balance <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions t WHERE t.customer_id = w.customer_id AND t.currency = 'POINT')
OR w.point_balance <> (SELECT COALESCE(SUM(remaining_amount), 0) FROM wallet_lots l WHERE l.customer_id = w.customer_id AND l.currency = 'POINT'))
UNION ALL
SELECT 'lot ' || l.id FROM wallet_lots l
WHERE l.customer_id IN ? AND l.original_amount - l.remaining_amount
<> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.lot_id = l.id)
UNION ALL
SELECT 'debit ' || t.id FROM wallet_transactions t
WHERE t.customer_id IN ? AND t.amount < 0
AND -t.amount <> (SELECT COALESCE(SUM(amount), 0) FROM wallet_lot_allocations a WHERE a.transaction_id = t.id)`,
customers, customers, customers).Scan(&broken).Error)
assert.Empty(t, broken)
}
+788
View File
@@ -0,0 +1,788 @@
package processor
import (
"context"
"errors"
"sort"
"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/repository"
)
// walletRepoFake is an in-memory WalletRepository with the same conditional-update
// semantics as the real one. Locks are only counted: these tests are single-threaded,
// and the locking itself is covered by the repository tests against Postgres.
type walletRepoFake struct {
customers map[uuid.UUID]uuid.UUID // customer -> organization
wallets map[uuid.UUID]*entities.CustomerWallet
transactions []*entities.WalletTransaction
lots []*entities.WalletLot
allocations []entities.WalletLotAllocation
locks map[uuid.UUID]int
clock time.Time
}
func newWalletRepoFake() *walletRepoFake {
return &walletRepoFake{
customers: map[uuid.UUID]uuid.UUID{},
wallets: map[uuid.UUID]*entities.CustomerWallet{},
locks: map[uuid.UUID]int{},
clock: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
}
func (f *walletRepoFake) tick() time.Time {
f.clock = f.clock.Add(time.Second)
return f.clock
}
func (f *walletRepoFake) LockWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
org, ok := f.customers[customerID]
if !ok {
return nil, repository.ErrWalletNotFound
}
if f.wallets[customerID] == nil {
f.wallets[customerID] = &entities.CustomerWallet{CustomerID: customerID, OrganizationID: org}
}
f.locks[customerID]++
w := *f.wallets[customerID]
return &w, nil
}
func (f *walletRepoFake) LockWallets(ctx context.Context, a, b uuid.UUID) (*entities.CustomerWallet, *entities.CustomerWallet, error) {
wa, err := f.LockWallet(ctx, a)
if err != nil {
return nil, nil, err
}
wb, err := f.LockWallet(ctx, b)
return wa, wb, err
}
func (f *walletRepoFake) AddBalance(_ context.Context, customerID uuid.UUID, currency string, delta int64) (int64, error) {
w := f.wallets[customerID]
if w == nil {
return 0, repository.ErrWalletNotFound
}
balance := &w.PointBalance
if currency == constants.WalletCurrencyCoin {
balance = &w.CoinBalance
}
if *balance+delta < 0 {
return 0, repository.ErrWalletInsufficientBalance
}
*balance += delta
return *balance, nil
}
func (f *walletRepoFake) GetWallet(_ context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
w := f.wallets[customerID]
if w == nil {
return nil, errors.New("not found")
}
c := *w
return &c, nil
}
func (f *walletRepoFake) CreateTransaction(_ context.Context, tx *entities.WalletTransaction) error {
if tx.IdempotencyKey != nil {
for _, t := range f.transactions {
if t.IdempotencyKey != nil && *t.IdempotencyKey == *tx.IdempotencyKey {
return errors.New("duplicate idempotency key")
}
}
}
if tx.ID == uuid.Nil {
tx.ID = uuid.New()
}
tx.CreatedAt = f.tick()
c := *tx
f.transactions = append(f.transactions, &c)
return nil
}
func (f *walletRepoFake) GetTransactionByIdempotencyKey(_ context.Context, key string) (*entities.WalletTransaction, error) {
for _, t := range f.transactions {
if t.IdempotencyKey != nil && *t.IdempotencyKey == key {
c := *t
return &c, nil
}
}
return nil, nil
}
func (f *walletRepoFake) CreateLot(_ context.Context, lot *entities.WalletLot) error {
if lot.ID == uuid.Nil {
lot.ID = uuid.New()
}
lot.CreatedAt = f.tick()
c := *lot
f.lots = append(f.lots, &c)
return nil
}
func (f *walletRepoFake) GetLotsByIDs(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
for _, id := range ids {
if lot.ID == id {
out = append(out, *lot)
break
}
}
}
return out, nil
}
func (f *walletRepoFake) ListLotsBySourceTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
if lot.SourceTransactionID == txID {
out = append(out, *lot)
}
}
return out, nil
}
func (f *walletRepoFake) ListActiveLots(_ context.Context, customerID uuid.UUID, currency string, asOf time.Time) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
if lot.CustomerID == customerID && lot.Currency == currency && lot.RemainingAmount > 0 &&
(lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
out = append(out, *lot)
}
}
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
switch {
case a.ExpiresAt == nil && b.ExpiresAt != nil:
return false
case a.ExpiresAt != nil && b.ExpiresAt == nil:
return true
case a.ExpiresAt != nil && !a.ExpiresAt.Equal(*b.ExpiresAt):
return a.ExpiresAt.Before(*b.ExpiresAt)
}
return a.CreatedAt.Before(b.CreatedAt)
})
return out, nil
}
func (f *walletRepoFake) ConsumeLot(_ context.Context, lotID uuid.UUID, amount int64) error {
for _, lot := range f.lots {
if lot.ID == lotID {
if lot.RemainingAmount < amount {
return repository.ErrWalletLotInsufficient
}
lot.RemainingAmount -= amount
return nil
}
}
return repository.ErrWalletLotInsufficient
}
func (f *walletRepoFake) CreateAllocations(_ context.Context, allocations []entities.WalletLotAllocation) error {
f.allocations = append(f.allocations, allocations...)
return nil
}
func (f *walletRepoFake) ListAllocationsByTransaction(_ context.Context, txID uuid.UUID) ([]entities.WalletLotAllocation, error) {
var out []entities.WalletLotAllocation
for _, a := range f.allocations {
if a.TransactionID == txID {
out = append(out, a)
}
}
return out, nil
}
// assertInvariants checks the reconciliation rules of §7.5 over everything the fake
// holds.
func (f *walletRepoFake) assertInvariants(t *testing.T) {
t.Helper()
allocatedFromLot := map[uuid.UUID]int64{}
allocatedByTx := map[uuid.UUID]int64{}
for _, a := range f.allocations {
allocatedFromLot[a.LotID] += a.Amount
allocatedByTx[a.TransactionID] += a.Amount
}
createdByTx := map[uuid.UUID]int64{}
for _, lot := range f.lots {
createdByTx[lot.SourceTransactionID] += lot.OriginalAmount
assert.Equal(t, lot.OriginalAmount-allocatedFromLot[lot.ID], lot.RemainingAmount, "lot %s: original - allocations = remaining", lot.ID)
}
for _, tx := range f.transactions {
if tx.Amount > 0 {
assert.Equal(t, tx.Amount, createdByTx[tx.ID], "credit %s: lots add up to the amount", tx.Type)
assert.Zero(t, allocatedByTx[tx.ID], "credit %s has no allocations", tx.Type)
} else {
assert.Equal(t, -tx.Amount, allocatedByTx[tx.ID], "debit %s: allocations add up to the amount", tx.Type)
assert.Zero(t, createdByTx[tx.ID], "debit %s creates no lots", tx.Type)
}
}
for customerID, w := range f.wallets {
for currency, balance := range map[string]int64{
constants.WalletCurrencyPoint: w.PointBalance,
constants.WalletCurrencyCoin: w.CoinBalance,
} {
var ledger, lots, last int64
for _, tx := range f.transactions {
if tx.CustomerID == customerID && tx.Currency == currency {
ledger += tx.Amount
last = tx.BalanceAfter
}
}
for _, lot := range f.lots {
if lot.CustomerID == customerID && lot.Currency == currency {
lots += lot.RemainingAmount
}
}
assert.Equal(t, balance, ledger, "%s balance = SUM(ledger)", currency)
assert.Equal(t, balance, lots, "%s balance = SUM(lot remaining)", currency)
assert.Equal(t, balance, last, "%s balance = last balance_after", currency)
}
}
}
type walletTestEnv struct {
repo *walletRepoFake
p *WalletProcessor
now time.Time
org uuid.UUID
ctx context.Context
}
func newWalletTestEnv(t *testing.T) *walletTestEnv {
repo := newWalletRepoFake()
env := &walletTestEnv{
repo: repo,
p: NewWalletProcessor(repo),
now: time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC),
org: uuid.New(),
ctx: context.Background(),
}
env.p.now = func() time.Time { return env.now }
t.Cleanup(func() { repo.assertInvariants(t) })
return env
}
func (e *walletTestEnv) customer() uuid.UUID {
id := uuid.New()
e.repo.customers[id] = e.org
return id
}
func (e *walletTestEnv) at(d time.Duration) *time.Time {
v := e.now.Add(d)
return &v
}
func ptr[T any](v T) *T { return &v }
func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCreditInput {
return WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarn,
Amount: amount,
ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: uuid.New(),
OutletID: ptr(uuid.New()),
Description: "Belanja",
},
Lots: []WalletLotInput{{Amount: amount, ExpiresAt: expiresAt}},
}
}
func pay(customerID uuid.UUID, amount int64) WalletDebitInput {
return WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypePayment,
Amount: amount,
ReferenceType: constants.WalletRefTypePayment,
ReferenceID: uuid.New(),
OutletID: ptr(uuid.New()),
Description: "Bayar",
}}
}
func (e *walletTestEnv) credit(t *testing.T, in WalletCreditInput) *WalletResult {
t.Helper()
res, err := e.p.Credit(e.ctx, in)
require.NoError(t, err)
return res
}
func (e *walletTestEnv) balance(t *testing.T, customerID uuid.UUID) int64 {
t.Helper()
w, err := e.repo.GetWallet(e.ctx, customerID)
require.NoError(t, err)
return w.PointBalance
}
func allocationsOf(res *WalletResult) map[uuid.UUID]int64 {
out := map[uuid.UUID]int64{}
for _, a := range res.Allocations {
out[a.LotID] = a.Amount
}
return out
}
func TestWalletProcessor_CreditCreatesLedgerRowAndLot(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
res := e.credit(t, earn(c, 100, e.at(24*time.Hour)))
assert.Equal(t, int64(100), res.Transaction.Amount)
assert.Equal(t, int64(100), res.Transaction.BalanceAfter)
assert.Equal(t, e.org, res.Transaction.OrganizationID, "organization comes from the wallet")
require.Len(t, res.Lots, 1)
assert.Equal(t, res.Transaction.ID, res.Lots[0].SourceTransactionID)
assert.Equal(t, e.at(24*time.Hour), res.Lots[0].ExpiresAt)
assert.Equal(t, int64(100), e.balance(t, c))
assert.Equal(t, 1, e.repo.locks[c], "credit locks the wallet itself")
}
func TestWalletProcessor_CreditWithoutLotsMakesOneNonExpiringLot(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
in := earn(c, 40, nil)
in.Lots = nil
res := e.credit(t, in)
require.Len(t, res.Lots, 1)
assert.Equal(t, int64(40), res.Lots[0].OriginalAmount)
assert.Nil(t, res.Lots[0].ExpiresAt)
}
func TestWalletProcessor_CreditRejectsLotsThatDoNotAddUp(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
in := earn(c, 100, nil)
in.Lots = []WalletLotInput{{Amount: 60}, {Amount: 30}}
_, err := e.p.Credit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
in.Lots = []WalletLotInput{{Amount: 100}, {Amount: 0}}
_, err = e.p.Credit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
assert.Empty(t, e.repo.transactions)
}
func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
first := e.credit(t, earn(c, 30, e.at(1*time.Hour))).Lots[0]
second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0]
third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0]
res, err := e.p.Debit(e.ctx, pay(c, 70))
require.NoError(t, err)
assert.Equal(t, int64(-70), res.Transaction.Amount)
assert.Equal(t, int64(50), res.Transaction.BalanceAfter)
assert.Equal(t, map[uuid.UUID]int64{first.ID: 30, second.ID: 40}, allocationsOf(res))
assert.Equal(t, first.ID, res.Allocations[0].LotID, "allocations are reported in the order used")
assert.Equal(t, e.at(1*time.Hour), res.Allocations[0].ExpiresAt)
lots, _ := e.repo.GetLotsByIDs(e.ctx, []uuid.UUID{first.ID, second.ID, third.ID})
remaining := map[uuid.UUID]int64{}
for _, l := range lots {
remaining[l.ID] = l.RemainingAmount
}
assert.Equal(t, map[uuid.UUID]int64{first.ID: 0, second.ID: 10, third.ID: 40}, remaining)
}
// K9: soonest expiry first, lots without an expiry last and oldest first among them,
// expired lots never.
func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
neverOld := e.credit(t, earn(c, 10, nil)).Lots[0]
late := e.credit(t, earn(c, 10, e.at(48*time.Hour))).Lots[0]
soon := e.credit(t, earn(c, 10, e.at(1*time.Hour))).Lots[0]
neverNew := e.credit(t, earn(c, 10, nil)).Lots[0]
e.credit(t, earn(c, 10, e.at(-1*time.Hour))) // already expired
var order []uuid.UUID
for i := 0; i < 4; i++ {
res, err := e.p.Debit(e.ctx, pay(c, 10))
require.NoError(t, err)
require.Len(t, res.Allocations, 1)
order = append(order, res.Allocations[0].LotID)
}
assert.Equal(t, []uuid.UUID{soon.ID, late.ID, neverOld.ID, neverNew.ID}, order)
// The expired lot still counts in the balance until the expiry job removes it,
// but it cannot be spent (§7.3).
assert.Equal(t, int64(10), e.balance(t, c))
_, err := e.p.Debit(e.ctx, pay(c, 10))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
}
func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
e.credit(t, earn(c, 50, nil))
_, err := e.p.Debit(e.ctx, pay(c, 51))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
assert.Equal(t, int64(50), e.balance(t, c))
assert.Len(t, e.repo.transactions, 1)
assert.Empty(t, e.repo.allocations)
// A customer who never had a wallet has nothing to spend.
_, err = e.p.Debit(e.ctx, pay(e.customer(), 1))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
}
func reversal(customerID uuid.UUID, amount int64, earnRes *WalletResult) WalletDebitInput {
in := WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeEarnReversal,
Amount: amount,
ReferenceType: constants.WalletRefTypeOrder,
ReferenceID: earnRes.Transaction.ReferenceID,
ReversesTransactionID: &earnRes.Transaction.ID,
OutletID: earnRes.Transaction.OutletID,
Description: "Batal",
}}
for _, lot := range earnRes.Lots {
in.PreferredLotIDs = append(in.PreferredLotIDs, lot.ID)
}
return in
}
func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
earned := e.credit(t, earn(c, 100, nil))
_, err := e.p.Debit(e.ctx, pay(c, 70))
require.NoError(t, err)
res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned))
require.NoError(t, err)
assert.Equal(t, int64(-30), res.Transaction.Amount)
assert.Equal(t, int64(70), res.Shortfall)
assert.Equal(t, int64(100), res.Transaction.Metadata["requested_amount"])
assert.Equal(t, int64(70), res.Transaction.Metadata["shortfall"])
assert.Equal(t, int64(0), e.balance(t, c))
// Nothing left: no ledger row, the whole amount is shortfall.
in := reversal(c, 5, earned)
res, err = e.p.DebitUpTo(e.ctx, in)
require.NoError(t, err)
assert.Nil(t, res.Transaction)
assert.Equal(t, int64(5), res.Shortfall)
assert.Len(t, e.repo.transactions, 3)
}
// A reversal draws from the lots its EARN created first (F10), even when an older lot
// would come first in K9 order, and even when that lot has expired.
func TestWalletProcessor_DebitDrawsPreferredLotsFirst(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
older := e.credit(t, earn(c, 50, e.at(1*time.Hour))).Lots[0]
earned := e.credit(t, earn(c, 20, e.at(-1*time.Hour)))
res, err := e.p.Debit(e.ctx, reversal(c, 30, earned))
require.NoError(t, err)
require.Len(t, res.Allocations, 2)
assert.Equal(t, earned.Lots[0].ID, res.Allocations[0].LotID)
assert.Equal(t, int64(20), res.Allocations[0].Amount)
assert.Equal(t, older.ID, res.Allocations[1].LotID)
assert.Equal(t, int64(10), res.Allocations[1].Amount)
}
func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) {
e := newWalletTestEnv(t)
a, b := e.customer(), e.customer()
e.credit(t, earn(a, 10, nil))
other := e.credit(t, earn(b, 10, nil))
in := pay(a, 5)
in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID}
_, err := e.p.Debit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
in.PreferredLotIDs = []uuid.UUID{uuid.New()}
_, err = e.p.Debit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
}
func TestWalletProcessor_ExpireDrawsTheExpiredLot(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
e.credit(t, earn(c, 10, nil))
expired := e.credit(t, earn(c, 25, e.at(-1*time.Hour))).Lots[0]
res, err := e.p.Debit(e.ctx, WalletDebitInput{
WalletEntry: WalletEntry{
CustomerID: c,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeExpire,
Amount: expired.RemainingAmount,
ReferenceType: constants.WalletRefTypeLot,
ReferenceID: expired.ID,
Description: "Kedaluwarsa",
IdempotencyKey: "expire:" + expired.ID.String(),
},
PreferredLotIDs: []uuid.UUID{expired.ID},
})
require.NoError(t, err)
assert.Equal(t, map[uuid.UUID]int64{expired.ID: 25}, allocationsOf(res))
assert.Equal(t, int64(10), e.balance(t, c))
}
func TestWalletProcessor_IdempotentCredit(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
in := earn(c, 100, nil)
in.IdempotencyKey = "earn:order-1"
first := e.credit(t, in)
in.ReferenceID = first.Transaction.ReferenceID
second := e.credit(t, in)
assert.True(t, second.Replayed)
assert.False(t, first.Replayed)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
assert.Equal(t, first.Lots[0].ID, second.Lots[0].ID)
assert.Equal(t, int64(100), e.balance(t, c))
assert.Len(t, e.repo.transactions, 1)
}
func TestWalletProcessor_IdempotentDebit(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
e.credit(t, earn(c, 30, e.at(time.Hour)))
e.credit(t, earn(c, 30, nil))
in := pay(c, 40)
in.IdempotencyKey = "pay:1"
first, err := e.p.Debit(e.ctx, in)
require.NoError(t, err)
second, err := e.p.Debit(e.ctx, in)
require.NoError(t, err)
assert.True(t, second.Replayed)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
assert.ElementsMatch(t, first.Allocations, second.Allocations)
assert.Equal(t, int64(20), e.balance(t, c))
}
func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
earned := e.credit(t, earn(c, 100, nil))
_, err := e.p.Debit(e.ctx, pay(c, 60))
require.NoError(t, err)
in := reversal(c, 100, earned)
in.IdempotencyKey = "reverse:order-1"
first, err := e.p.DebitUpTo(e.ctx, in)
require.NoError(t, err)
e.credit(t, earn(c, 500, nil)) // new balance must not be taken by the retry
second, err := e.p.DebitUpTo(e.ctx, in)
require.NoError(t, err)
assert.True(t, second.Replayed)
assert.Equal(t, first.Transaction.ID, second.Transaction.ID)
assert.Equal(t, int64(60), second.Shortfall)
assert.Equal(t, int64(500), e.balance(t, c))
}
func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
in := earn(c, 100, nil)
in.IdempotencyKey = "k"
e.credit(t, in)
other := earn(c, 99, nil)
other.IdempotencyKey = "k"
_, err := e.p.Credit(e.ctx, other)
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
debit := pay(c, 100)
debit.IdempotencyKey = "k"
_, err = e.p.Debit(e.ctx, debit)
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
otherCustomer := earn(e.customer(), 100, nil)
otherCustomer.IdempotencyKey = "k"
_, err = e.p.Credit(e.ctx, otherCustomer)
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
}
// A transfer debits the sender and credits the receiver with lots that keep the
// sender's expiry (K9), following the example in §8.
func TestWalletProcessor_TransferCarriesExpiry(t *testing.T) {
e := newWalletTestEnv(t)
a, b := e.customer(), e.customer()
dec := e.credit(t, earn(a, 100, e.at(30*24*time.Hour))).Lots[0]
jan := e.credit(t, earn(a, 50, e.at(60*24*time.Hour))).Lots[0]
require.NoError(t, e.p.LockWallets(e.ctx, a, b))
group, outID, inID := uuid.New(), uuid.New(), uuid.New()
out, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: WalletEntry{
TransactionID: outID, CustomerID: a, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferOut, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: inID,
GroupID: &group, CounterpartyCustomerID: &b, Description: "Transfer ke B",
}})
require.NoError(t, err)
received, err := e.p.Credit(e.ctx, WalletCreditInput{
WalletEntry: WalletEntry{
TransactionID: inID, CustomerID: b, Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypeTransferIn, Amount: 120,
ReferenceType: constants.WalletRefTypeWalletTx, ReferenceID: outID,
GroupID: &group, CounterpartyCustomerID: &a, Description: "Transfer dari A",
},
Lots: out.CarryOver(),
})
require.NoError(t, err)
assert.Equal(t, outID, out.Transaction.ID)
assert.Equal(t, inID, received.Transaction.ID)
require.Len(t, received.Lots, 2)
assert.Equal(t, int64(100), received.Lots[0].OriginalAmount)
assert.Equal(t, dec.ExpiresAt, received.Lots[0].ExpiresAt)
assert.Equal(t, &dec.ID, received.Lots[0].OriginLotID)
assert.Equal(t, int64(20), received.Lots[1].OriginalAmount)
assert.Equal(t, jan.ExpiresAt, received.Lots[1].ExpiresAt)
assert.Equal(t, &jan.ID, received.Lots[1].OriginLotID)
assert.Equal(t, int64(30), e.balance(t, a))
assert.Equal(t, int64(120), e.balance(t, b))
}
func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
c := uuid.New()
outlet := ptr(uuid.New())
credits := map[string]func(*WalletCreditInput){
"unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" },
"debit-only type as credit": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypePayment
in.ReferenceType = constants.WalletRefTypePayment
},
"unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" },
"zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil },
"negative amount": func(in *WalletCreditInput) { in.Amount = -5; in.Lots = nil },
"wrong reference type": func(in *WalletCreditInput) { in.ReferenceType = constants.WalletRefTypeUser },
"missing reference id": func(in *WalletCreditInput) { in.ReferenceID = uuid.Nil },
"missing description": func(in *WalletCreditInput) { in.Description = " " },
"EARN without outlet": func(in *WalletCreditInput) { in.OutletID = nil },
"missing customer": func(in *WalletCreditInput) { in.CustomerID = uuid.Nil },
"EXCHANGE_IN in COIN": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeExchangeIn
in.Currency = constants.WalletCurrencyCoin
in.ReferenceType = constants.WalletRefTypeWalletTx
in.GroupID = ptr(uuid.New())
},
"TRANSFER_IN to self": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeTransferIn
in.ReferenceType = constants.WalletRefTypeWalletTx
in.GroupID = ptr(uuid.New())
in.CounterpartyCustomerID = &in.CustomerID
},
"TRANSFER_IN without group": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeTransferIn
in.ReferenceType = constants.WalletRefTypeWalletTx
in.CounterpartyCustomerID = ptr(uuid.New())
},
"PAYMENT_REFUND without source": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypePaymentRefund
in.ReferenceType = constants.WalletRefTypePayment
},
"ADJUSTMENT without reason": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeAdjustment
in.ReferenceType = constants.WalletRefTypeUser
in.CreatedByUser = ptr(uuid.New())
},
"ADJUSTMENT blank reason": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeAdjustment
in.ReferenceType = constants.WalletRefTypeUser
in.CreatedByUser = ptr(uuid.New())
in.Reason = ptr(" ")
},
"ADJUSTMENT without admin": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeAdjustment
in.ReferenceType = constants.WalletRefTypeUser
in.Reason = ptr("komplain")
},
"MIGRATION wrong reference": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeMigration
in.ReferenceType = constants.WalletRefTypeOrder
},
}
for name, mutate := range credits {
t.Run("credit/"+name, func(t *testing.T) {
e := newWalletTestEnv(t)
e.repo.customers[c] = e.org
in := earn(c, 10, nil)
in.OutletID = outlet
mutate(&in)
_, err := e.p.Credit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
assert.Empty(t, e.repo.transactions)
})
}
debits := map[string]func(*WalletDebitInput){
"credit-only type as debit": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeMigration
in.ReferenceType = constants.WalletRefTypeLegacyPoints
},
"PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
"GAME_SPEND in POINT": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeGameSpend
in.ReferenceType = constants.WalletRefTypeGamePlay
},
"EXPIRE not pointing at a lot": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeExpire
in.ReferenceType = constants.WalletRefTypeOrder
},
"EARN_REVERSAL without source": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeEarnReversal
in.ReferenceType = constants.WalletRefTypeOrder
},
"TRANSFER_OUT without counterparty": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeTransferOut
in.ReferenceType = constants.WalletRefTypeWalletTx
in.GroupID = ptr(uuid.New())
},
"REWARD_REDEEM wrong reference": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeRewardRedeem
in.ReferenceType = constants.WalletRefTypeOrder
},
}
for name, mutate := range debits {
t.Run("debit/"+name, func(t *testing.T) {
e := newWalletTestEnv(t)
e.repo.customers[c] = e.org
e.credit(t, earn(c, 100, nil))
in := pay(c, 10)
mutate(&in)
_, err := e.p.Debit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
assert.Len(t, e.repo.transactions, 1)
})
}
}
func TestWalletProcessor_UnknownCustomer(t *testing.T) {
e := newWalletTestEnv(t)
_, err := e.p.Credit(e.ctx, earn(uuid.New(), 10, nil))
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
@@ -0,0 +1,317 @@
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"
)
// ErrInvalidWalletQuery wraps every rejection of a malformed history query, so the
// handler can answer with a validation error rather than a server error.
var ErrInvalidWalletQuery = errors.New("invalid wallet query")
const (
walletRecentTransactions = 5
walletLegacyHistorySize = 10
walletTransactionsPageLimit = 20
walletTransactionsMaxLimit = 100
)
// walletDisplayLocation is where the customer's calendar days start and end, for
// date filters. Same zone as the database connection and the expiry grouping.
var walletDisplayLocation = func() *time.Location {
loc, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return time.FixedZone("WIB", 7*60*60)
}
return loc
}()
// WalletQueryProcessor answers what the customer app shows about a wallet: balances,
// history and upcoming expiry (docs/prd-point-coin.md F6). It only reads.
type WalletQueryProcessor struct {
repo repository.WalletQueryRepository
settings pointValueReader
now func() time.Time
}
// pointValueReader gives the rupiah value of one EnakPoint in an organization.
// LoyaltySettingsProcessor is one.
type pointValueReader interface {
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
}
func NewWalletQueryProcessor(repo repository.WalletQueryRepository, settings pointValueReader) *WalletQueryProcessor {
return &WalletQueryProcessor{repo: repo, settings: settings, now: time.Now}
}
// Summary is GET /customer/wallet. A customer who has never had a wallet gets zeros.
func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerWalletResponseData, error) {
organizationID, err := p.repo.GetCustomerOrganizationID(ctx, customerID)
if err != nil {
return nil, err
}
now := p.now()
balances, err := p.repo.SpendableBalances(ctx, customerID, now)
if err != nil {
return nil, err
}
pointValue, err := p.settings.PointValue(ctx, organizationID)
if err != nil {
return nil, err
}
expiring, err := p.repo.NearestExpiring(ctx, customerID, now)
if err != nil {
return nil, err
}
recent, err := p.recent(ctx, customerID, "", walletRecentTransactions)
if err != nil {
return nil, err
}
points, err := p.legacyPoints(ctx, customerID, balances)
if err != nil {
return nil, err
}
tokens, err := p.legacyTokens(ctx, customerID, balances)
if err != nil {
return nil, err
}
data := &models.GetCustomerWalletResponseData{
PointBalance: balances[constants.WalletCurrencyPoint],
CoinBalance: balances[constants.WalletCurrencyCoin],
PointValue: pointValue,
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
RecentTransactions: recent,
TotalPoints: points.TotalPoints,
TotalTokens: tokens.TotalTokens,
PointsHistory: points.PointsHistory,
TokensHistory: tokens.TokensHistory,
LastUpdated: points.LastUpdated,
}
if tokens.LastUpdated.After(data.LastUpdated) {
data.LastUpdated = tokens.LastUpdated
}
for _, e := range expiring {
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
switch e.Currency {
case constants.WalletCurrencyPoint:
data.NearestExpiring.Point = entry
case constants.WalletCurrencyCoin:
data.NearestExpiring.Coin = entry
}
}
return data, nil
}
// Transactions is GET /customer/wallet/transactions.
func (p *WalletQueryProcessor) Transactions(ctx context.Context, customerID uuid.UUID, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
filter, page, err := walletTransactionFilter(customerID, query)
if err != nil {
return nil, err
}
rows, total, err := p.repo.ListTransactions(ctx, filter)
if err != nil {
return nil, err
}
items, err := p.toTransactions(ctx, rows)
if err != nil {
return nil, err
}
totalPages := int((total + int64(filter.Limit) - 1) / int64(filter.Limit))
return &models.PaginatedResponse[models.CustomerWalletTransaction]{
Data: items,
Pagination: models.Pagination{Page: page, Limit: filter.Limit, Total: total, TotalPages: totalPages},
}, nil
}
// LegacyPoints is the pre-wallet GET /customer/points, now read from the wallet.
func (p *WalletQueryProcessor) LegacyPoints(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerPointsResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyPoints(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerPointsResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyPoint, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerPointsResponseData{TotalPoints: balances[constants.WalletCurrencyPoint]}
for _, tx := range recent {
data.PointsHistory = append(data.PointsHistory, models.PointsHistoryItem{
ID: tx.ID.String(), Points: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyTokens(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
for _, tx := range recent {
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
CustomerID: customerID, Currency: currency, Limit: limit,
})
if err != nil {
return nil, err
}
return p.toTransactions(ctx, rows)
}
func (p *WalletQueryProcessor) toTransactions(ctx context.Context, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
return buildWalletTransactions(ctx, p.repo, rows)
}
// buildWalletTransactions turns ledger rows into what the apps show, in the same
// order: where each row came from or went to, and the lots each credit created.
func buildWalletTransactions(ctx context.Context, repo repository.WalletQueryRepository, rows []entities.WalletTransaction) ([]models.CustomerWalletTransaction, error) {
var credits []uuid.UUID
for _, row := range rows {
if row.Amount > 0 {
credits = append(credits, row.ID)
}
}
lots, err := repo.ListLotsBySourceTransactions(ctx, credits)
if err != nil {
return nil, err
}
lotsByTx := make(map[uuid.UUID][]entities.WalletLot, len(credits))
for _, lot := range lots {
lotsByTx[lot.SourceTransactionID] = append(lotsByTx[lot.SourceTransactionID], lot)
}
items := make([]models.CustomerWalletTransaction, 0, len(rows))
for _, row := range rows {
ref := &models.CustomerWalletTransactionRef{Type: row.ReferenceType, ID: row.ReferenceID}
item := models.CustomerWalletTransaction{
ID: row.ID,
Currency: row.Currency,
Type: row.Type,
Amount: row.Amount,
BalanceAfter: row.BalanceAfter,
Description: row.Description,
OutletID: row.OutletID,
ReversesTransactionID: row.ReversesTransactionID,
GroupID: row.GroupID,
CreatedAt: row.CreatedAt,
}
// The counterparty's id, the admin and the metadata are left out on purpose:
// the customer sees the masked name in the description (§8.1).
if row.Amount > 0 {
item.Source = ref
for _, lot := range lotsByTx[row.ID] {
item.Lots = append(item.Lots, models.CustomerWalletLot{
Amount: lot.OriginalAmount, Remaining: lot.RemainingAmount, ExpiresAt: lot.ExpiresAt,
})
if lot.ExpiresAt != nil && (item.ExpiresAt == nil || lot.ExpiresAt.Before(*item.ExpiresAt)) {
item.ExpiresAt = lot.ExpiresAt
}
}
} else {
item.Destination = ref
}
items = append(items, item)
}
return items, nil
}
func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTransactionsQuery) (repository.WalletTransactionFilter, int, error) {
invalid := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidWalletQuery, fmt.Sprintf(format, args...))
}
filter := repository.WalletTransactionFilter{CustomerID: customerID}
page := q.Page
if page == 0 {
page = 1
}
if page < 1 {
return filter, 0, invalid("page must be at least 1")
}
filter.Limit = q.Limit
if filter.Limit == 0 {
filter.Limit = walletTransactionsPageLimit
}
if filter.Limit < 1 || filter.Limit > walletTransactionsMaxLimit {
return filter, 0, invalid("limit must be between 1 and %d", walletTransactionsMaxLimit)
}
filter.Offset = (page - 1) * filter.Limit
if q.Currency != "" {
currency := strings.ToUpper(q.Currency)
if !constants.IsValidWalletCurrency(currency) {
return filter, 0, invalid("currency must be POINT or COIN")
}
filter.Currency = currency
}
for _, t := range strings.Split(q.Type, ",") {
t = strings.ToUpper(strings.TrimSpace(t))
if t == "" {
continue
}
if _, ok := walletTypeRules[t]; !ok {
return filter, 0, invalid("unknown type %q", t)
}
filter.Types = append(filter.Types, t)
}
if q.From != "" {
from, err := time.ParseInLocation("2006-01-02", q.From, walletDisplayLocation)
if err != nil {
return filter, 0, invalid("from must be a date like 2026-01-31")
}
filter.From = &from
}
if q.To != "" {
to, err := time.ParseInLocation("2006-01-02", q.To, walletDisplayLocation)
if err != nil {
return filter, 0, invalid("to must be a date like 2026-01-31")
}
// To is inclusive for the customer, exclusive in the query.
end := to.AddDate(0, 0, 1)
filter.To = &end
}
if filter.From != nil && filter.To != nil && !filter.From.Before(*filter.To) {
return filter, 0, invalid("from must not be after to")
}
return filter, page, nil
}
@@ -0,0 +1,245 @@
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"
)
type walletQueryRepoFake struct {
org uuid.UUID
spendable map[string]int64
expiring []repository.WalletExpiringAmount
transactions []entities.WalletTransaction
total int64
lots []entities.WalletLot
filters []repository.WalletTransactionFilter
}
func (f *walletQueryRepoFake) GetCustomerOrganizationID(context.Context, uuid.UUID) (uuid.UUID, error) {
if f.org == uuid.Nil {
return uuid.Nil, repository.ErrWalletNotFound
}
return f.org, nil
}
func (f *walletQueryRepoFake) SpendableBalances(context.Context, uuid.UUID, time.Time) (map[string]int64, error) {
return f.spendable, nil
}
func (f *walletQueryRepoFake) NearestExpiring(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
return f.expiring, nil
}
func (f *walletQueryRepoFake) ListTransactions(_ context.Context, filter repository.WalletTransactionFilter) ([]entities.WalletTransaction, int64, error) {
f.filters = append(f.filters, filter)
var out []entities.WalletTransaction
for _, tx := range f.transactions {
if filter.Currency == "" || tx.Currency == filter.Currency {
out = append(out, tx)
}
}
if len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, f.total, nil
}
func (f *walletQueryRepoFake) ListLotsBySourceTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
var out []entities.WalletLot
for _, lot := range f.lots {
for _, id := range ids {
if lot.SourceTransactionID == id {
out = append(out, lot)
}
}
}
return out, nil
}
type orgSettingsFake map[string]string
func newWalletQueryTest(repo *walletQueryRepoFake, settings orgSettingsFake) *WalletQueryProcessor {
p := NewWalletQueryProcessor(repo, NewLoyaltySettingsProcessor(&loyaltyRepoFake{orgValues: settings}, txRunnerFake{}))
p.now = func() time.Time { return time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) }
return p
}
func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.T) {
order, payment := uuid.New(), uuid.New()
earnID, payID := uuid.New(), uuid.New()
soon := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
created := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 250, constants.WalletCurrencyCoin: 8},
expiring: []repository.WalletExpiringAmount{
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
},
transactions: []entities.WalletTransaction{
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
},
lots: []entities.WalletLot{
{SourceTransactionID: earnID, OriginalAmount: 200, RemainingAmount: 150, ExpiresAt: &later},
{SourceTransactionID: earnID, OriginalAmount: 100, RemainingAmount: 100, ExpiresAt: &soon},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{constants.LoyaltyPointValueKey: "100"})
data, err := p.Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(250), data.PointBalance)
assert.Equal(t, int64(8), data.CoinBalance)
assert.Equal(t, int64(100), data.PointValue)
assert.Equal(t, int64(25000), data.PointDiscountValue)
assert.Equal(t, &models.CustomerWalletExpiring{Amount: 100, Date: "2026-07-01"}, data.NearestExpiring.Point)
assert.Nil(t, data.NearestExpiring.Coin)
require.Len(t, data.RecentTransactions, 2)
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
assert.Nil(t, pay.Source)
assert.Empty(t, pay.Lots)
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
assert.Nil(t, earn.Destination)
assert.Equal(t, &soon, earn.ExpiresAt, "earliest expiry among the lots the credit created")
assert.Len(t, earn.Lots, 2)
// The pre-wallet fields are still filled, from the wallet.
assert.Equal(t, int64(250), data.TotalPoints)
assert.Equal(t, int64(8), data.TotalTokens)
require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
}
func TestWalletQueryProcessor_PointValueFallsBackToDefault(t *testing.T) {
for name, settings := range map[string]orgSettingsFake{
"not set": {},
"garbage": {constants.LoyaltyPointValueKey: "abc"},
"zero": {constants.LoyaltyPointValueKey: "0"},
"negative": {constants.LoyaltyPointValueKey: "-5"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{constants.WalletCurrencyPoint: 40}}
data, err := newWalletQueryTest(repo, settings).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, constants.LoyaltyPointValueDefault, data.PointValue)
assert.Equal(t, int64(40), data.PointDiscountValue)
})
}
}
func TestWalletQueryProcessor_SummaryForCustomerWithoutWallet(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), spendable: map[string]int64{}}
data, err := newWalletQueryTest(repo, orgSettingsFake{}).Summary(context.Background(), uuid.New())
require.NoError(t, err)
assert.Zero(t, data.PointBalance)
assert.Zero(t, data.CoinBalance)
assert.NotNil(t, data.RecentTransactions, "an empty list, not null, for the app")
_, err = newWalletQueryTest(&walletQueryRepoFake{}, orgSettingsFake{}).Summary(context.Background(), uuid.New())
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
}
func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
repo := &walletQueryRepoFake{
org: uuid.New(),
spendable: map[string]int64{constants.WalletCurrencyPoint: 100, constants.WalletCurrencyCoin: 8},
transactions: []entities.WalletTransaction{
{ID: uuid.New(), Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration, Amount: 8},
{ID: uuid.New(), Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: 100},
},
}
p := newWalletQueryTest(repo, orgSettingsFake{})
points, err := p.LegacyPoints(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(100), points.TotalPoints)
require.Len(t, points.PointsHistory, 1)
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(8), tokens.TotalTokens)
require.Len(t, tokens.TokensHistory, 1)
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
}
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
customer := uuid.New()
jakarta := walletDisplayLocation
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
})
require.NoError(t, err)
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
assert.NotNil(t, page.Data)
f := repo.filters[0]
assert.Equal(t, customer, f.CustomerID)
assert.Equal(t, 20, f.Offset)
assert.Equal(t, 10, f.Limit)
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
// Defaults.
repo = &walletQueryRepoFake{org: uuid.New()}
page, err = newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{})
require.NoError(t, err)
assert.Equal(t, 1, page.Pagination.Page)
assert.Equal(t, walletTransactionsPageLimit, repo.filters[0].Limit)
assert.Zero(t, repo.filters[0].Offset)
assert.Nil(t, repo.filters[0].From)
assert.Empty(t, repo.filters[0].Types)
}
func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
for name, q := range map[string]models.ListCustomerWalletTransactionsQuery{
"negative page": {Page: -1},
"limit too big": {Limit: walletTransactionsMaxLimit + 1},
"negative limit": {Limit: -1},
"unknown currency": {Currency: "GOLD"},
"unknown type": {Type: "EARN,BONUS"},
"bad from": {From: "01-05-2026"},
"bad to": {To: "2026-13-01"},
"from after to": {From: "2026-06-02", To: "2026-06-01"},
} {
t.Run(name, func(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(), q)
assert.ErrorIs(t, err, ErrInvalidWalletQuery)
assert.Empty(t, repo.filters, "nothing is queried")
})
}
// A single day is a valid range.
repo := &walletQueryRepoFake{org: uuid.New()}
_, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), uuid.New(),
models.ListCustomerWalletTransactionsQuery{From: "2026-06-01", To: "2026-06-01"})
assert.NoError(t, err)
}
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
return 0, 0, nil
}
+34 -19
View File
@@ -29,6 +29,20 @@ type AnalyticsRepository interface {
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
}
// A weight-based line is a single weighing: order_items.quantity is pinned to 1 and
// the weighed amount lives in order_items.weight, while unit_price and unit_cost are
// per unit of weight. Anything that multiplies or divides by a per-unit rate must use
// the weighed amount, not the row count, or a 4.2 ons fish is costed as a single ons.
// These mirror entities.OrderItem.BillableQuantity for aggregate SQL.
const (
// billableQty is the multiplier for a per-unit price or cost, before refunds.
billableQty = `COALESCE(oi.weight, oi.quantity)`
// billableQtyNet is billableQty after refunds. A weighed line is always refunded
// whole, so with quantity pinned to 1 the factor is the full weight or zero.
billableQtyNet = `COALESCE(oi.weight, 1) * (oi.quantity - COALESCE(oi.refund_quantity, 0))`
)
type AnalyticsRepositoryImpl struct {
db *gorm.DB
}
@@ -461,9 +475,10 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
c.name as category_name,
c.order as category_order,
COALESCE(SUM(oi.quantity), 0) as quantity_sold,
COALESCE(SUM(oi.weight), 0) as weight_sold,
COALESCE(SUM(oi.total_price), 0) as revenue,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(oi.quantity)
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,
@@ -478,14 +493,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(oi.quantity), 0) as standard_hpp_total,
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
CASE
WHEN SUM(oi.quantity) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(oi.quantity)
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(oi.quantity), 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").
@@ -536,9 +551,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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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").
@@ -589,9 +604,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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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").
@@ -706,9 +721,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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
`).
Scan(summary).Error
if err != nil {
@@ -727,9 +742,9 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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) * (oi.quantity - COALESCE(oi.refund_quantity, 0)) 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
`).
Group("c.id, c.name, c.order").
Order("c.order ASC, c.name ASC").
@@ -759,18 +774,18 @@ func (r *AnalyticsRepositoryImpl) GetParentCategoryAnalyticsDetail(ctx context.C
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END), 0) as revenue,
COALESCE(
SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_price - COALESCE(oi.refund_amount, 0) ELSE 0 END)
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
0) as average_price,
COUNT(DISTINCT oi.order_id) as order_count,
COALESCE(shpp.hpp_per_unit, p.cost, 0) as standard_hpp_per_unit,
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as standard_hpp_total,
COALESCE(shpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as standard_hpp_total,
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)
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0),
/ NULLIF(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0),
0) as fifo_hpp_per_unit,
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 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(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as moving_average_hpp_total
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN ` + billableQtyNet + ` ELSE 0 END), 0) as moving_average_hpp_total
`).
Group("p.id, p.name, p.sku, p.price, p.cost, pop.price, c.id, c.name, c.order, shpp.hpp_per_unit, mahpp.hpp_per_unit").
Order("revenue DESC").
@@ -1167,9 +1182,9 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
}
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
@@ -0,0 +1,220 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ErrPinCustomerNotFound means the customer does not exist.
var ErrPinCustomerNotFound = errors.New("pin: customer not found")
// CustomerPinState is a customer's PIN and what guards it. It lives in the customers
// table but is read and written only here, never through the Customer entity, so the
// hash cannot end up in a customer response.
type CustomerPinState struct {
CustomerID uuid.UUID
OrganizationID uuid.UUID
PhoneNumber *string
BirthDate *time.Time
PinHash *string
PinSetAt *time.Time
FailedAttempts int
LockedUntil *time.Time
TransferBlockedUntil *time.Time
}
// CustomerSecurityEvent is one row of the PIN security log.
type CustomerSecurityEvent struct {
ID uuid.UUID
CustomerID uuid.UUID
Event string
ActorUser *uuid.UUID
Reason *string
IPAddress *string
UserAgent *string
CreatedAt time.Time
}
// CustomerPinRepository stores customer PINs and their security log
// (docs/prd-point-coin.md F11).
type CustomerPinRepository interface {
GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error)
// SetPin stores a new PIN hash, clears the failure counter and any lock, and sets
// or clears the transfer hold.
SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error
// RemovePin deletes the PIN, so the customer has to create a new one through OTP.
RemovePin(ctx context.Context, customerID uuid.UUID) error
// RecordFailure adds one wrong attempt in a single statement, so wrong attempts
// made at the same time all count. A lock that has already run out starts the
// count again. When the count reaches maxAttempts the PIN is locked until
// lockUntil. It returns the count and lock after the update.
RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error)
ClearFailures(ctx context.Context, customerID uuid.UUID) error
InsertEvent(ctx context.Context, event CustomerSecurityEvent) error
// ListEvents returns a page of the customer's log, newest first, and the total.
ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error)
}
type customerPinRepository struct {
db *gorm.DB
}
func NewCustomerPinRepository(db *gorm.DB) CustomerPinRepository {
return &customerPinRepository{db: db}
}
func (r *customerPinRepository) GetState(ctx context.Context, customerID uuid.UUID) (*CustomerPinState, error) {
var rows []struct {
CustomerID string
OrganizationID string
PhoneNumber *string
BirthDate *time.Time
PinHash *string
PinSetAt *time.Time
PinFailedAttempts int
PinLockedUntil *time.Time
TransferBlockedUntil *time.Time
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT id::text AS customer_id, organization_id::text AS organization_id,
COALESCE(phone_number, phone) AS phone_number, birth_date,
pin_hash, pin_set_at, pin_failed_attempts, pin_locked_until, transfer_blocked_until
FROM customers WHERE id = ? LIMIT 1`, customerID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read customer PIN: %w", err)
}
if len(rows) == 0 {
return nil, ErrPinCustomerNotFound
}
row := rows[0]
state := &CustomerPinState{
PhoneNumber: row.PhoneNumber,
BirthDate: row.BirthDate,
PinHash: row.PinHash,
PinSetAt: row.PinSetAt,
FailedAttempts: row.PinFailedAttempts,
LockedUntil: row.PinLockedUntil,
TransferBlockedUntil: row.TransferBlockedUntil,
}
state.CustomerID, _ = uuid.Parse(row.CustomerID)
state.OrganizationID, _ = uuid.Parse(row.OrganizationID)
return state, nil
}
func (r *customerPinRepository) SetPin(ctx context.Context, customerID uuid.UUID, hash string, transferBlockedUntil *time.Time) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_hash = ?, pin_set_at = NOW(), pin_failed_attempts = 0,
pin_locked_until = NULL, transfer_blocked_until = ?, updated_at = NOW()
WHERE id = ?`, hash, transferBlockedUntil, customerID)
if result.Error != nil {
return fmt.Errorf("failed to store customer PIN: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrPinCustomerNotFound
}
return nil
}
func (r *customerPinRepository) RemovePin(ctx context.Context, customerID uuid.UUID) error {
result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_hash = NULL, pin_set_at = NULL, pin_failed_attempts = 0,
pin_locked_until = NULL, updated_at = NOW()
WHERE id = ?`, customerID)
if result.Error != nil {
return fmt.Errorf("failed to remove customer PIN: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrPinCustomerNotFound
}
return nil
}
func (r *customerPinRepository) RecordFailure(ctx context.Context, customerID uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
var rows []struct {
PinFailedAttempts int
PinLockedUntil *time.Time
}
// When an earlier lock has run out, this attempt is the first of a new series.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
UPDATE customers SET
pin_failed_attempts = CASE
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN 1
ELSE pin_failed_attempts + 1 END,
pin_locked_until = CASE
WHEN pin_locked_until IS NOT NULL AND pin_locked_until <= @now THEN NULL
WHEN pin_failed_attempts + 1 >= @max THEN @lock
ELSE pin_locked_until END
WHERE id = @id
RETURNING pin_failed_attempts, pin_locked_until`,
map[string]interface{}{"now": now, "max": maxAttempts, "lock": lockUntil, "id": customerID}).
Scan(&rows).Error
if err != nil {
return 0, nil, fmt.Errorf("failed to record a wrong PIN: %w", err)
}
if len(rows) == 0 {
return 0, nil, ErrPinCustomerNotFound
}
return rows[0].PinFailedAttempts, rows[0].PinLockedUntil, nil
}
func (r *customerPinRepository) ClearFailures(ctx context.Context, customerID uuid.UUID) error {
return DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE customers SET pin_failed_attempts = 0, pin_locked_until = NULL
WHERE id = ? AND (pin_failed_attempts <> 0 OR pin_locked_until IS NOT NULL)`, customerID).Error
}
func (r *customerPinRepository) InsertEvent(ctx context.Context, event CustomerSecurityEvent) error {
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
INSERT INTO customer_security_events (customer_id, event, actor_user, reason, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)`,
event.CustomerID, event.Event, event.ActorUser, event.Reason, event.IPAddress, event.UserAgent).Error
if err != nil {
return fmt.Errorf("failed to record security event: %w", err)
}
return nil
}
func (r *customerPinRepository) ListEvents(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]CustomerSecurityEvent, int64, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
var total int64
if err := db.Table("customer_security_events").Where("customer_id = ?", customerID).Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count security events: %w", err)
}
var rows []struct {
ID string
CustomerID string
Event string
ActorUser *string
Reason *string
IPAddress *string
UserAgent *string
CreatedAt time.Time
}
err := db.Raw(`
SELECT id::text AS id, customer_id::text AS customer_id, event, actor_user::text AS actor_user,
reason, ip_address, user_agent, created_at
FROM customer_security_events WHERE customer_id = ?
ORDER BY created_at DESC, id DESC OFFSET ? LIMIT ?`, customerID, offset, limit).Scan(&rows).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list security events: %w", err)
}
events := make([]CustomerSecurityEvent, 0, len(rows))
for _, row := range rows {
e := CustomerSecurityEvent{Event: row.Event, Reason: row.Reason, IPAddress: row.IPAddress, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt}
e.ID, _ = uuid.Parse(row.ID)
e.CustomerID, _ = uuid.Parse(row.CustomerID)
if row.ActorUser != nil {
if id, err := uuid.Parse(*row.ActorUser); err == nil {
e.ActorUser = &id
}
}
events = append(events, e)
}
return events, total, nil
}
+269
View File
@@ -0,0 +1,269 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// ErrEarningOrderNotFound means the order does not exist.
var ErrEarningOrderNotFound = errors.New("earning: order not found")
// EarningOrder is what earning needs to know about an order.
type EarningOrder struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
OrderNumber string
OutletName string
CustomerID *uuid.UUID
Subtotal float64
DiscountAmount float64
PaymentStatus string
IsVoid bool
// Cumulative rupiah refunded so far.
RefundAmount float64
// Nil when the order has no customer, or the customer row is gone.
CustomerIsDefault *bool
CustomerIsActive *bool
}
// EarningCursor pages through orders by (updated_at, id).
type EarningCursor struct {
UpdatedAt time.Time
ID uuid.UUID
}
// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3).
type EarningRepository interface {
GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error)
// PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns
// nothing (Q10). Zero until EnakPoint payment exists (phase 3).
PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error)
// ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since
// the given time that are paid, not void, have an eligible customer, belong to an
// outlet that earns something, and have no EARN row yet. Pass the previous page's
// last cursor to continue; nil starts at the beginning.
ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error)
// ListEarnTransactions returns the EARN rows written for an order.
ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error)
// ReversalRequested is how much has been asked back from an EARN row so far: what
// was taken plus any shortfall.
ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error)
// LotIDsCreatedBy returns the lots a credit created.
LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error)
// EarnedByOrders sums the EARN rows of each order per currency, as
// map[order]map[currency]amount. Orders that earned nothing are absent.
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error)
}
type earningRepository struct {
db *gorm.DB
}
func NewEarningRepository(db *gorm.DB) EarningRepository {
return &earningRepository{db: db}
}
func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error) {
var rows []struct {
ID string
OrganizationID string
OutletID string
OrderNumber string
OutletName string
CustomerID *string
Subtotal float64
DiscountAmount float64
PaymentStatus string
IsVoid bool
RefundAmount float64
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.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,
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 = ?
LIMIT 1`, orderID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to get order for earning: %w", err)
}
if len(rows) == 0 {
return nil, ErrEarningOrderNotFound
}
row := rows[0]
order := &EarningOrder{
OrderNumber: row.OrderNumber,
OutletName: row.OutletName,
Subtotal: row.Subtotal,
DiscountAmount: row.DiscountAmount,
PaymentStatus: row.PaymentStatus,
IsVoid: row.IsVoid,
RefundAmount: row.RefundAmount,
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 *earningRepository) PointPaidAmount(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 pm.type = ? AND p.status = ?`,
orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted).
Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
}
return total, nil
}
func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) {
cursorAt, cursorID := since, uuid.Nil
if after != nil {
cursorAt, cursorID = after.UpdatedAt, after.ID
}
var rows []struct {
ID string
UpdatedAt time.Time
}
// An outlet that has neither currency switched on can never earn, so its orders are
// not candidates; otherwise every order of such an outlet would be rescanned on
// every run.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT o.id::text AS id, o.updated_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.payment_status = ?
AND COALESCE(o.is_void, false) = false
AND c.is_default = false AND c.is_active = true
AND o.updated_at >= ?
AND (o.updated_at, o.id) > (?, ?)
AND EXISTS (
SELECT 1 FROM outlet_settings s
WHERE s.outlet_id = o.outlet_id
AND s.key IN (?, ?)
AND lower(trim(s.value)) IN ('true', 't', '1')
)
AND NOT EXISTS (
SELECT 1 FROM wallet_transactions t
WHERE t.reference_type = ? AND t.reference_id = o.id AND t.type = ?
)
ORDER BY o.updated_at, o.id
LIMIT ?`,
entities.PaymentStatusCompleted, since, cursorAt, cursorID,
constants.LoyaltyPointEnabledKey, constants.LoyaltyCoinEnabledKey,
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, limit).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list paid orders without earning: %w", err)
}
out := make([]EarningCursor, 0, len(rows))
for _, row := range rows {
if id, err := uuid.Parse(row.ID); err == nil {
out = append(out, EarningCursor{UpdatedAt: row.UpdatedAt, ID: id})
}
}
return out, nil
}
func (r *earningRepository) ListEarnTransactions(ctx context.Context, orderID uuid.UUID) ([]entities.WalletTransaction, error) {
var rows []entities.WalletTransaction
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("reference_type = ? AND reference_id = ? AND type = ?", constants.WalletRefTypeOrder, orderID, constants.WalletTxTypeEarn).
Order("currency").
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list EARN rows: %w", err)
}
return rows, nil
}
func (r *earningRepository) ReversalRequested(ctx context.Context, earnTransactionID uuid.UUID) (int64, error) {
var total int64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(COALESCE((metadata->>'requested_amount')::bigint, -amount)), 0)
FROM wallet_transactions
WHERE reverses_transaction_id = ? AND type = ?`,
earnTransactionID, constants.WalletTxTypeEarnReversal).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum earning reversals: %w", err)
}
return total, nil
}
func (r *earningRepository) LotIDsCreatedBy(ctx context.Context, transactionID uuid.UUID) ([]uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("wallet_lots").
Where("source_transaction_id = ?", transactionID).
Order("expires_at NULLS LAST, created_at, id").
Pluck("id::text", &ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list lots of a credit: %w", err)
}
out := make([]uuid.UUID, 0, len(ids))
for _, s := range ids {
if id, err := uuid.Parse(s); err == nil {
out = append(out, id)
}
}
return out, nil
}
func (r *earningRepository) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]map[string]int64, error) {
earned := map[uuid.UUID]map[string]int64{}
if len(orderIDs) == 0 {
return earned, nil
}
var rows []struct {
OrderID string
Currency string
Amount int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT reference_id::text AS order_id, currency, SUM(amount) AS amount
FROM wallet_transactions
WHERE reference_type = ? AND type = ? AND reference_id IN ?
GROUP BY reference_id, currency`,
constants.WalletRefTypeOrder, constants.WalletTxTypeEarn, orderIDs).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to sum earnings of orders: %w", err)
}
for _, row := range rows {
id, err := uuid.Parse(row.OrderID)
if err != nil {
continue
}
if earned[id] == nil {
earned[id] = map[string]int64{}
}
earned[id][row.Currency] = row.Amount
}
return earned, nil
}
@@ -0,0 +1,176 @@
package repository
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// ErrLoyaltyOutletNotFound means the outlet does not exist or belongs to another
// organization.
var ErrLoyaltyOutletNotFound = errors.New("loyalty settings: outlet not found")
// LoyaltySettingChangeFilter narrows the loyalty settings history.
type LoyaltySettingChangeFilter struct {
OrganizationID uuid.UUID
// Nil for the whole organization, outlets included.
OutletID *uuid.UUID
Offset int
Limit int
}
// LoyaltySettingsRepository stores the raw loyalty settings (the loyalty.* keys of
// outlet_settings and organization_settings) and their history
// (docs/prd-point-coin.md F1, F2). The loyalty settings processor turns them into
// typed values and is the only writer.
type LoyaltySettingsRepository interface {
// GetOutletValues returns the stored loyalty keys of an outlet. A key with no row,
// or a NULL value, is absent.
GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error)
GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error)
// OutletOrganizationID returns ErrLoyaltyOutletNotFound for an unknown outlet.
OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error)
// LockScope serializes writers of one outlet's or one organization's settings
// until the transaction ends, so two saves at once cannot both compute their
// changes from the same old values.
LockScope(ctx context.Context, scopeID uuid.UUID) error
// SetOutletValue stores a value, or removes the key when value is nil so it falls
// back to its default.
SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error
SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
// ListChanges returns a page of history, newest first, and the total count.
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
}
type loyaltySettingsRepository struct {
db *gorm.DB
}
func NewLoyaltySettingsRepository(db *gorm.DB) LoyaltySettingsRepository {
return &loyaltySettingsRepository{db: db}
}
// requireTx returns the caller's transaction. Writes must run in one, together with
// the history rows they produce.
func (r *loyaltySettingsRepository) requireTx(ctx context.Context) (*gorm.DB, error) {
if tx, ok := ctx.Value(txKey).(*gorm.DB); ok && tx != nil {
return tx.WithContext(ctx), nil
}
return nil, errors.New("loyalty settings: write must run inside a transaction")
}
func (r *loyaltySettingsRepository) values(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID) (map[string]string, error) {
var rows []struct {
Key string
Value string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table(table).
Select("key, value").
Where(scopeColumn+" = ? AND key LIKE 'loyalty.%' AND value IS NOT NULL", scopeID).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", table, err)
}
values := make(map[string]string, len(rows))
for _, row := range rows {
values[row.Key] = row.Value
}
return values, nil
}
func (r *loyaltySettingsRepository) GetOutletValues(ctx context.Context, outletID uuid.UUID) (map[string]string, error) {
return r.values(ctx, "outlet_settings", "outlet_id", outletID)
}
func (r *loyaltySettingsRepository) GetOrganizationValues(ctx context.Context, organizationID uuid.UUID) (map[string]string, error) {
return r.values(ctx, "organization_settings", "organization_id", organizationID)
}
func (r *loyaltySettingsRepository) OutletOrganizationID(ctx context.Context, outletID uuid.UUID) (uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("outlets").
Where("id = ?", outletID).
Limit(1).
Pluck("organization_id::text", &ids).Error
if err != nil {
return uuid.Nil, fmt.Errorf("failed to get outlet organization: %w", err)
}
if len(ids) == 0 {
return uuid.Nil, ErrLoyaltyOutletNotFound
}
return uuid.Parse(ids[0])
}
func (r *loyaltySettingsRepository) LockScope(ctx context.Context, scopeID uuid.UUID) error {
db, err := r.requireTx(ctx)
if err != nil {
return err
}
return db.Exec(`SELECT pg_advisory_xact_lock(hashtextextended(?, 0))`, "loyalty-settings:"+scopeID.String()).Error
}
func (r *loyaltySettingsRepository) SetOutletValue(ctx context.Context, outletID uuid.UUID, key string, value *string) error {
return r.setValue(ctx, "outlet_settings", "outlet_id", outletID, key, value)
}
func (r *loyaltySettingsRepository) SetOrganizationValue(ctx context.Context, organizationID uuid.UUID, key string, value *string) error {
return r.setValue(ctx, "organization_settings", "organization_id", organizationID, key, value)
}
// setValue upserts or deletes one key. table and scopeColumn are never user input.
func (r *loyaltySettingsRepository) setValue(ctx context.Context, table, scopeColumn string, scopeID uuid.UUID, key string, value *string) error {
db, err := r.requireTx(ctx)
if err != nil {
return err
}
if value == nil {
err = db.Exec(`DELETE FROM `+table+` WHERE `+scopeColumn+` = ? AND key = ?`, scopeID, key).Error
} else {
err = db.Exec(`INSERT INTO `+table+` (`+scopeColumn+`, key, value) VALUES (?, ?, ?)
ON CONFLICT (`+scopeColumn+`, key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
scopeID, key, *value).Error
}
if err != nil {
return fmt.Errorf("failed to store %s in %s: %w", key, table, err)
}
return nil
}
func (r *loyaltySettingsRepository) InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error {
if len(changes) == 0 {
return nil
}
db, err := r.requireTx(ctx)
if err != nil {
return err
}
return db.Create(&changes).Error
}
func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error) {
query := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.LoyaltySettingChange{}).
Where("organization_id = ?", filter.OrganizationID)
if filter.OutletID != nil {
query = query.Where("outlet_id = ?", *filter.OutletID)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count loyalty setting changes: %w", err)
}
var changes []entities.LoyaltySettingChange
err := query.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&changes).Error
if err != nil {
return nil, 0, fmt.Errorf("failed to list loyalty setting changes: %w", err)
}
return changes, total, nil
}
@@ -0,0 +1,149 @@
package repository_test
import (
"context"
"os"
"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"
applogger "apskel-pos-be/internal/logger"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// Reads and writes loyalty settings through the processor against Postgres. Needs
// TEST_DATABASE_URL pointing at a migrated database; see wallet_repository_test.go.
func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
applogger.Setup("fatal", "json")
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
ctx := context.Background()
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
exec := func(q string, args ...any) {
t.Helper()
require.NoError(t, db.Exec(q, args...).Error)
}
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'loyalty test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
// A setting that is not a loyalty setting must be left alone.
exec(`INSERT INTO outlet_settings (outlet_id, key, value) VALUES (?, 'receipt.footer', 'Terima kasih')`, outlet)
t.Cleanup(func() {
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
db.Exec(`DELETE FROM organization_settings WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
})
repo := repository.NewLoyaltySettingsRepository(db)
p := processor.NewLoyaltySettingsProcessor(repo, repository.NewTxManager(db))
// No settings: every PRD default.
settings, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
assert.False(t, settings.Point.Enabled)
assert.Equal(t, int64(100), settings.Point.EarnPerAmount)
assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount)
assert.Nil(t, settings.Point.MaxPerOrder)
assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent)
// Change three keys.
settings.Point.Enabled = true
settings.Point.EarnPerAmount = 1000
settings.Coin.MaxPerOrder = ptrInt64(5)
changes, err := p.UpdateOutlet(ctx, org, outlet, admin, *settings)
require.NoError(t, err)
assert.Len(t, changes, 3)
var stored []struct{ Key, Value string }
require.NoError(t, db.Raw(`SELECT key, value FROM outlet_settings WHERE outlet_id = ? ORDER BY key`, outlet).Scan(&stored).Error)
assert.Equal(t, []struct{ Key, Value string }{
{constants.LoyaltyCoinMaxPerOrderKey, "5"},
{constants.LoyaltyPointEarnPerAmountKey, "1000"},
{constants.LoyaltyPointEnabledKey, "true"},
{"receipt.footer", "Terima kasih"},
}, stored, "only the changed keys are stored, next to the untouched non-loyalty key")
history, err := p.ListChanges(ctx, org, &outlet, 1, 20)
require.NoError(t, err)
require.Len(t, history.Data, 3)
for _, c := range history.Data {
assert.Equal(t, admin, c.ChangedBy)
assert.Nil(t, c.OldValue, "%s was on its default", c.Key)
assert.NotNil(t, c.NewValue)
}
// A second change records the old value.
settings.Point.EarnPerAmount = 500
changes, err = p.UpdateOutlet(ctx, org, outlet, admin, *settings)
require.NoError(t, err)
require.Len(t, changes, 1)
assert.Equal(t, "1000", *changes[0].OldValue)
assert.Equal(t, "500", *changes[0].NewValue)
// Another organization cannot change this outlet.
_, err = p.UpdateOutlet(ctx, otherOrg, outlet, admin, *settings)
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
// Writing outside a transaction is refused.
assert.Error(t, repo.SetOutletValue(ctx, outlet, constants.LoyaltyPointEnabledKey, nil))
// A save must wait while another save of the same organization holds the lock, so
// two admins saving at once cannot both compute their change from the same old
// value. Hold the lock in another transaction and check the save blocks until it
// is released.
txm := repository.NewTxManager(db)
holding, release, holderDone := make(chan struct{}), make(chan struct{}), make(chan error, 1)
go func() {
holderDone <- txm.WithTransaction(ctx, func(ctx context.Context) error {
if err := repo.LockScope(ctx, org); err != nil {
return err
}
close(holding)
<-release
return nil
})
}()
<-holding
orgSettings, err := p.Organization(ctx, org)
require.NoError(t, err)
orgSettings.PointValue = 200
saved := make(chan error, 1)
go func() {
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
saved <- err
}()
select {
case err := <-saved:
t.Fatalf("the save finished while another transaction held the lock (err: %v)", err)
case <-time.After(300 * time.Millisecond):
}
close(release)
require.NoError(t, <-holderDone)
require.NoError(t, <-saved)
value, err := p.PointValue(ctx, org)
require.NoError(t, err)
assert.Equal(t, int64(200), value)
// The whole organization's history includes the outlet's changes.
all, err := p.ListChanges(ctx, org, nil, 1, 100)
require.NoError(t, err)
assert.EqualValues(t, 5, all.Pagination.Total, "four outlet changes and one organization change")
}
func ptrInt64(v int64) *int64 { return &v }
+14 -7
View File
@@ -50,6 +50,7 @@ func (r *OrderItemRepositoryImpl) GetByOrderID(ctx context.Context, orderID uuid
err := r.db.WithContext(ctx).
Preload("Product").
Preload("ProductVariant").
Preload("Unit").
Where("order_id = ?", orderID).
Find(&orderItems).Error
return orderItems, err
@@ -109,8 +110,12 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
return err
}
if voidQuantity >= orderItem.Quantity {
voidedAmount := float64(voidQuantity) * orderItem.UnitPrice
// A weighed line is one weighing, so it is voided whole — never split into a
// remainder row. chk_order_items_weight_single_line pins its quantity to 1, which
// already sends it down this branch; the explicit check keeps that guarantee
// visible and independent of the comparison below.
if orderItem.IsWeighed() || voidQuantity >= orderItem.Quantity {
voidedAmount := entities.RoundMoney(orderItem.BillableQuantityFor(voidQuantity) * orderItem.UnitPrice)
updates := map[string]interface{}{
"refund_quantity": voidQuantity,
@@ -128,16 +133,18 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
Updates(updates).Error
}
// Only unit-priced lines reach here: weighed lines were voided whole above.
voidedOrderItem := entities.OrderItem{
OrderID: orderItem.OrderID,
ProductID: orderItem.ProductID,
ProductVariantID: orderItem.ProductVariantID,
Quantity: voidQuantity,
UnitID: orderItem.UnitID,
UnitPrice: orderItem.UnitPrice,
TotalPrice: float64(voidQuantity) * orderItem.UnitPrice,
TotalPrice: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
UnitCost: orderItem.UnitCost,
TotalCost: float64(voidQuantity) * orderItem.UnitCost,
RefundAmount: float64(voidQuantity) * orderItem.UnitPrice,
TotalCost: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitCost),
RefundAmount: entities.RoundMoney(float64(voidQuantity) * orderItem.UnitPrice),
RefundQuantity: voidQuantity,
IsPartiallyRefunded: false,
IsFullyRefunded: true,
@@ -155,8 +162,8 @@ func (r *OrderItemRepositoryImpl) VoidOrderItem(ctx context.Context, id uuid.UUI
}
remainingQuantity := orderItem.Quantity - voidQuantity
remainingTotalPrice := float64(remainingQuantity) * orderItem.UnitPrice
remainingTotalCost := float64(remainingQuantity) * orderItem.UnitCost
remainingTotalPrice := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitPrice)
remainingTotalCost := entities.RoundMoney(float64(remainingQuantity) * orderItem.UnitCost)
updates := map[string]interface{}{
"quantity": remainingQuantity,
+3
View File
@@ -63,6 +63,7 @@ func (r *OrderRepositoryImpl) GetWithRelations(ctx context.Context, id uuid.UUID
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -144,6 +145,7 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -162,6 +164,7 @@ func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID str
Preload("OrderItems.Product.Category").
Preload("OrderItems.Product.ProductOutletPrices").
Preload("OrderItems.ProductVariant").
Preload("OrderItems.Unit").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
@@ -0,0 +1,103 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
var (
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
ErrPaymentCodeTaken = errors.New("payment code already in use")
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
// already used.
ErrPaymentCodeNotFound = errors.New("payment code not found")
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
)
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
type PaymentCodeRepository interface {
// Save stores a code for a customer for ttl, and retires the customer's previous
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
// Consume uses a code up if it belongs to the customer. A code of another customer
// is left in place, so a cashier scanning it against the wrong order does not burn
// it for its owner.
Consume(ctx context.Context, code string, customerID uuid.UUID) error
}
type paymentCodeRepository struct {
client *redis.Client
}
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
return &paymentCodeRepository{client: client}
}
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
func paymentCodeCustomerKey(customerID uuid.UUID) string {
return "wallet:paycode:customer:" + customerID.String()
}
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
if err != nil {
return fmt.Errorf("failed to store payment code: %w", err)
}
if !ok {
return ErrPaymentCodeTaken
}
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("failed to track payment code: %w", err)
}
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
if previous != "" && previous != code {
// Only if it is still that customer's: the number may have been reissued.
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
return err
}
}
return nil
}
// consumeScript deletes a code only if it belongs to the given customer, in one step.
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
var consumeScript = redis.NewScript(`
local owner = redis.call('GET', KEYS[1])
if not owner then return 0 end
if owner ~= ARGV[1] then return -1 end
redis.call('DEL', KEYS[1])
return 1
`)
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimSpace(code)
if code == "" {
return ErrPaymentCodeNotFound
}
return r.compareAndDelete(ctx, code, customerID)
}
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
if err != nil {
return fmt.Errorf("failed to use payment code: %w", err)
}
switch result {
case 1:
return nil
case -1:
return ErrPaymentCodeWrongCustomer
default:
return ErrPaymentCodeNotFound
}
}
@@ -75,6 +75,8 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
case "exclude_type":
query = query.Where("type <> ?", value)
default:
query = query.Where(key+" = ?", value)
}
+7
View File
@@ -50,3 +50,10 @@ func (m *TxManager) WithTransactionOptions(ctx context.Context, opts *sql.TxOpti
return fn(ctxTx)
}, opts)
}
// DetachTransaction returns ctx without the caller's transaction, so work started from
// it (such as loyalty earning after a payment) reads committed data and commits on its
// own, whatever happens to the caller's transaction.
func DetachTransaction(ctx context.Context) context.Context {
return context.WithValue(ctx, txKey, (*gorm.DB)(nil))
}
@@ -0,0 +1,126 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/entities"
)
// WalletCustomer is the customer a dashboard wallet screen is about.
type WalletCustomer struct {
ID uuid.UUID
Name string
Phone *string
}
// WalletAdminRepository serves what the dashboard adds on top of the customer's own
// wallet view (docs/prd-point-coin.md F7): the raw balances, every lot with balance,
// and the real names behind a ledger row. Nothing here changes a balance.
type WalletAdminRepository interface {
// GetCustomer returns ErrWalletNotFound unless the customer exists and belongs to
// the organization, so one organization can never read another's customers.
GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error)
// GetWallet returns nil, nil for a customer who has never had a wallet.
GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error)
// ListLotsWithBalance returns every lot that still holds something, expired or
// not, in K9 spending order.
ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error)
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
}
type walletAdminRepository struct {
db *gorm.DB
}
func NewWalletAdminRepository(db *gorm.DB) WalletAdminRepository {
return &walletAdminRepository{db: db}
}
func (r *walletAdminRepository) GetCustomer(ctx context.Context, organizationID, customerID uuid.UUID) (*WalletCustomer, error) {
var customers []WalletCustomer
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("customers").
Select("id, name, COALESCE(phone_number, phone) AS phone").
Where("id = ? AND organization_id = ?", customerID, organizationID).
Limit(1).
Scan(&customers).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer: %w", err)
}
if len(customers) == 0 {
return nil, ErrWalletNotFound
}
return &customers[0], nil
}
func (r *walletAdminRepository) GetWallet(ctx context.Context, customerID uuid.UUID) (*entities.CustomerWallet, error) {
var wallets []entities.CustomerWallet
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ?", customerID).
Limit(1).
Find(&wallets).Error
if err != nil {
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
}
if len(wallets) == 0 {
return nil, nil
}
return &wallets[0], nil
}
func (r *walletAdminRepository) ListLotsWithBalance(ctx context.Context, customerID uuid.UUID) ([]entities.WalletLot, error) {
var lots []entities.WalletLot
err := DBFromContext(ctx, r.db).WithContext(ctx).
Where("customer_id = ? AND remaining_amount > 0", customerID).
Order("currency, expires_at NULLS LAST, 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 *walletAdminRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "customers", ids)
}
func (r *walletAdminRepository) UserNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "users", ids)
}
func (r *walletAdminRepository) OutletNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return r.names(ctx, "outlets", ids)
}
// names looks up the name column of a table by id. The table name is never taken
// from user input.
func (r *walletAdminRepository) names(ctx context.Context, table string, ids []uuid.UUID) (map[uuid.UUID]string, error) {
names := make(map[uuid.UUID]string, len(ids))
if len(ids) == 0 {
return names, nil
}
var rows []struct {
ID string
Name string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table(table).
Select("id::text AS id, name").
Where("id IN ?", ids).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to look up %s names: %w", table, err)
}
for _, row := range rows {
if id, err := uuid.Parse(row.ID); err == nil {
names[id] = row.Name
}
}
return names, nil
}
@@ -0,0 +1,134 @@
package repository
import (
"context"
"fmt"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// LegacyBalance is what one customer holds in customer_points and customer_tokens,
// the tables the wallet replaces (docs/prd-point-coin.md §10).
type LegacyBalance struct {
CustomerID uuid.UUID
// Nil when the customer has no customer_points row.
PointsRowID *uuid.UUID
Points int64
Tokens []entities.CustomerTokens
}
// Coins is the sum of every token type: all of them become EnakCoin (Q6).
func (b LegacyBalance) Coins() int64 {
var total int64
for _, t := range b.Tokens {
total += t.Balance
}
return total
}
// WalletMigrationTotals compares the legacy tables with what has been migrated.
type WalletMigrationTotals struct {
LegacyPoints int64
LegacyCoins int64
MigratedPoints int64
MigratedCoins int64
WalletPoints int64
WalletCoins int64
}
// WalletMigrationRepository reads the legacy balances for the one-time move into the
// wallet. The writes go through the wallet processor like any other credit.
type WalletMigrationRepository interface {
// ListLegacyCustomers returns, in id order, up to limit customers after the given
// id that have a row in customer_points or customer_tokens.
ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error)
GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error)
// SumMigrated returns how much has already been credited to the customer by
// MIGRATION ledger rows in the currency.
SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error)
Totals(ctx context.Context) (*WalletMigrationTotals, error)
}
type walletMigrationRepository struct {
db *gorm.DB
}
func NewWalletMigrationRepository(db *gorm.DB) WalletMigrationRepository {
return &walletMigrationRepository{db: db}
}
func (r *walletMigrationRepository) ListLegacyCustomers(ctx context.Context, after uuid.UUID, limit int) ([]uuid.UUID, error) {
var ids []uuid.UUID
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT customer_id FROM (
SELECT customer_id FROM customer_points
UNION
SELECT customer_id FROM customer_tokens
) legacy
WHERE customer_id > ?
ORDER BY customer_id
LIMIT ?`, after, limit).
Scan(&ids).Error
if err != nil {
return nil, fmt.Errorf("failed to list legacy customers: %w", err)
}
return ids, nil
}
func (r *walletMigrationRepository) GetLegacyBalance(ctx context.Context, customerID uuid.UUID) (*LegacyBalance, error) {
db := DBFromContext(ctx, r.db).WithContext(ctx)
balance := &LegacyBalance{CustomerID: customerID}
// Find rather than First: many customers have tokens but no points row, and First
// would log each of them as a "record not found" error.
var points []entities.CustomerPoints
if err := db.Where("customer_id = ?", customerID).Limit(1).Find(&points).Error; err != nil {
return nil, fmt.Errorf("failed to get legacy points: %w", err)
}
if len(points) > 0 {
balance.PointsRowID = &points[0].ID
balance.Points = points[0].Balance
}
err := db.Where("customer_id = ?", customerID).Order("token_type").Find(&balance.Tokens).Error
if err != nil {
return nil, fmt.Errorf("failed to get legacy tokens: %w", err)
}
return balance, nil
}
func (r *walletMigrationRepository) SumMigrated(ctx context.Context, customerID uuid.UUID, currency string) (int64, error) {
var total int64
err := DBFromContext(ctx, r.db).WithContext(ctx).
Model(&entities.WalletTransaction{}).
Where("customer_id = ? AND currency = ? AND type = ?", customerID, currency, constants.WalletTxTypeMigration).
Select("COALESCE(SUM(amount), 0)").
Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum migrated balance: %w", err)
}
return total, nil
}
func (r *walletMigrationRepository) Totals(ctx context.Context) (*WalletMigrationTotals, error) {
var totals WalletMigrationTotals
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT
(SELECT COALESCE(SUM(balance), 0) FROM customer_points) AS legacy_points,
(SELECT COALESCE(SUM(balance), 0) FROM customer_tokens) AS legacy_coins,
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_points,
(SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE type = ? AND currency = ?) AS migrated_coins,
(SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets) AS wallet_points,
(SELECT COALESCE(SUM(coin_balance), 0) FROM customer_wallets) AS wallet_coins`,
constants.WalletTxTypeMigration, constants.WalletCurrencyPoint,
constants.WalletTxTypeMigration, constants.WalletCurrencyCoin).
Scan(&totals).Error
if err != nil {
return nil, fmt.Errorf("failed to compute wallet migration totals: %w", err)
}
return &totals, nil
}

Some files were not shown because too many files have changed in this diff Show More