Adds POST /customer/orders/:id/pay-with-points (docs/prd-point-coin.md F9,
PC-306) for the customer app and self-order. It uses the same payment path
as the cashier, approved by the customer's PIN instead of a code: the
session alone is not enough (K8), and a wrong PIN takes nothing and counts
toward the lock.
A customer can pay only their own order; any other order, and one that
does not exist, answer 404 alike, so the endpoint does not reveal other
customers' orders. The method is the organization's EnakPoint method, no
cashier is recorded, and settling the order triggers earning through the
same onOrderPaid hook as every other payment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds paying with the EnakPoint method (docs/prd-point-coin.md F9, K7,
PC-305).
POST /payments with the EnakPoint method now takes points and the
customer's payment code and goes through PointPaymentProcessor instead of
the generic path, which would record a payment without taking any balance.
After checking the order, its customer (not walk-in, active), the outlet
(accepts EnakPoint, minimum) and the method, it redeems the code, then in
one transaction locks the order row and the wallet, recomputes the F9
limits from fresh data, inserts the payment with points_used and the frozen
point_value, writes the PAYMENT ledger row (key payment:{id}, the outlet,
the cashier) and updates the order. The limits are
min(balance, floor(min(remaining, total × max_payment_percent / 100 − paid
with EnakPoint) / point_value)) in cents, so EnakPoint never pays more than
what is left and gives no change.
Unlike the generic CreatePayment, which always marks the order paid, an
EnakPoint payment leaves it partial with the right remaining amount until
it is settled, so the rest can be paid in cash. Settling it triggers
earning, whose basis leaves out the EnakPoint part. Splitting with the
EnakPoint method is refused. Refusals answer 400. The payment response
carries points_used and point_value for the receipt.
GET /orders/:id/point-payment/preview returns eligibility, balance, point
value and the maximum for the use-maximum button.
The payment and order repositories write outside transactions, so this
path uses its own repository that joins it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>