Commit Graph
495 Commits
Author SHA1 Message Date
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: #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: #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: #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: #28
2026-08-13 09:39:53 +02:00
Efril 2c6864147b feat: cash advance 2026-08-13 14:38:28 +07:00
aefril b42d141927 Merge pull request 'Dev' (#27) from dev into main
Reviewed-on: #27
2026-08-11 17:46:43 +02:00
Efril e6078e3c0b feat(purchase 2026-08-11 22:41:10 +07:00
efrilm dbc143954c feat(purchase): unwired unit convertions 2026-08-11 21:24:24 +07:00
efrilm 0726fcecf0 feat(ingredients): make units nullable 2026-08-11 21:20:17 +07:00
efrilm 9ae5be2c33 feat(purchasae): added team with category parent and central 2026-08-11 21:00:39 +07:00
aefril a230199ce0 Merge pull request 'feat(products): sort by' (#26) from staging into main
Reviewed-on: #26
2026-08-11 15:48:00 +02:00
Efril 4f7e774043 feat(products): sort by 2026-08-11 20:47:38 +07:00
aefril 793ef10ce8 Merge pull request 'feat(category): update list filter category' (#25) from staging into main
Reviewed-on: #25
2026-08-11 15:42:49 +02:00
Efril a7c2d6cbb3 feat(category): update list filter category 2026-08-06 21:21:41 +07:00
aefril 1d412959d7 Merge pull request 'feat: profit sharing' (#24) from staging into main
Reviewed-on: #24
2026-08-05 12:39:49 +00:00
Efril b9ac97178f feat: profit sharing 2026-08-05 19:28:38 +07:00
aefril 7b46da7007 Merge pull request 'staging' (#23) from staging into main
Reviewed-on: #23
2026-07-09 15:43:23 +00:00
Efril 2b80c92caa feat: deployment 2026-07-09 22:43:01 +07:00
Efril f7dd0bd5e8 config: update port 2026-07-09 22:19:53 +07:00
Efril 1533914e4d config: prod and staging 2026-07-09 22:11:03 +07:00
Efril bfce4b865b update purchase date 2026-07-02 12:49:28 +07:00
Efril 581e4a5453 update purchase date 2026-07-02 12:23:12 +07:00
Efril 9b0fc9a63b feat: updat analytic profit loss add purchasing 2026-06-24 00:11:04 +07:00
Efril 793919cf10 feat: add outlet name at analytic response and new overview dashboard 2026-06-23 22:18:16 +07:00
aefril 25024c210a Merge pull request 'fix: product price' (#21) from feature/exclusive-summary into main
Reviewed-on: #21
2026-06-22 06:34:02 +00:00
efrilm 3977370079 fix: product price 2026-06-22 13:33:25 +07:00
Efril 37bcb90ab0 feat: ensure all role 2026-06-19 13:58:36 +07:00
Efril e345aeee97 feat: new users role 2026-06-19 13:31:33 +07:00
Efril 486d94335b Merge branch 'main' of https://gits.altru.id/apksel-dev/apskel-pos-backend 2026-06-18 19:55:04 +07:00
aefril 7d5acb33e8 Merge pull request 'Update profit-loss' (#20) from feature/exclusive-summary into main
Reviewed-on: #20
2026-06-18 12:53:23 +00:00
ryan 2138b44c53 Update profit-loss 2026-06-18 19:51:43 +07:00
Efril 503fb5734f fix: migration 82 2026-06-18 16:34:37 +07:00
aefril ac06a4bbe9 Merge pull request 'feature/exclusive-summary' (#19) from feature/exclusive-summary into main
Reviewed-on: #19
2026-06-18 09:12:13 +00:00
ryan 87540fa1b7 Update exclusive summary 2026-06-18 15:44:15 +07:00