Files
aura-crm/production/backend/internal/cash/handler.go
T

354 lines
14 KiB
Go

// Package cash implements Phase 7's manual cash ledger — cash_transactions
// records income (client paid on-site or via безнал-счёт), expenses, and
// payroll. A manual payroll row (flat amount, no formula) is still always
// possible through Create below — see migrations/006_cash.sql for why
// nothing here is auto-recorded from order/status changes or from Phase
// 5's stock receipts. internal/payroll now posts computed payroll rows too
// (shift rate + order-profit commission + cartridge piece rate), but only
// through RecordPayroll in expense.go, never through this Handler.
package cash
import (
"context"
"log"
"math"
"strconv"
"time"
"unicode/utf8"
"production/internal/auth"
"production/internal/authz"
"production/internal/dbutil"
"production/internal/kkm"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
maxLongFieldLen = 5000
maxAmountLen = 32
// Matches the amount column's own NUMERIC(10,2) range.
maxAmount = 99_999_999.99
)
var validTypes = map[string]bool{"income": true, "expense": true, "payroll": true}
var validMethods = map[string]bool{"cash": true, "card": true, "invoice": true}
type Handler struct {
db *pgxpool.Pool
kkm kkm.Registrar
}
func NewHandler(db *pgxpool.Pool, kkm kkm.Registrar) *Handler {
return &Handler{db: db, kkm: kkm}
}
type createInput struct {
Type string `json:"type"`
Method string `json:"method"`
Amount string `json:"amount"`
OrderID string `json:"order_id"`
CartridgeBatchID string `json:"cartridge_batch_id"`
RegisterID string `json:"register_id"`
CategoryID string `json:"category_id"`
Note string `json:"note"`
IsPending bool `json:"is_pending"`
}
func (b createInput) validate() string {
if !validTypes[b.Type] {
return "type must be one of: income, expense, payroll"
}
if !validMethods[b.Method] {
return "method must be one of: cash, card, invoice"
}
if b.Amount == "" {
return "amount is required"
}
if len(b.Amount) > maxAmountLen {
return "amount is too long"
}
// Signed and nonzero, not just positive — a correction is recorded as a
// same-type row with the offsetting negative amount (see
// migrations/006_cash.sql for why). maxAmount matches the NUMERIC(10,2)
// column's own range, so an out-of-range value is a clean 400 here
// rather than a Postgres numeric_field_overflow surfacing as a bare 500;
// IsNaN/IsInf are checked explicitly since ParseFloat accepts both and
// they'd otherwise slip past the range check undetected in either
// direction.
amt, err := strconv.ParseFloat(b.Amount, 64)
if err != nil || amt == 0 || math.IsNaN(amt) || math.IsInf(amt, 0) || amt > maxAmount || amt < -maxAmount {
return "amount must be a nonzero number, magnitude at most " + strconv.FormatFloat(maxAmount, 'f', 2, 64)
}
if b.OrderID != "" && b.CartridgeBatchID != "" {
return "at most one of order_id, cartridge_batch_id may be set"
}
if b.CategoryID != "" && b.Type != "expense" {
return "category_id only applies to type=expense"
}
if b.IsPending && b.Type != "income" {
return "is_pending only applies to type=income"
}
if utf8.RuneCountInString(b.Note) > maxLongFieldLen {
return "note is too long"
}
return ""
}
// Create validates that method is one of the chosen register's own accepted
// types when register_id is set — a register can now hold several types at
// once (see registers.go), so it can no longer silently override whatever
// the client sent the way a single-type register used to; a mismatch is a
// real 400 instead. No register (the three automatic writers — order
// prepayment, POS checkout, trade-in payout — don't set one yet) skips the
// check entirely, unchanged from before registers existed.
func (h *Handler) Create(c *fiber.Ctx) error {
var body createInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if msg := body.validate(); msg != "" {
return c.Status(400).JSON(fiber.Map{"error": msg})
}
ctx := context.Background()
if body.RegisterID != "" {
var types []string
if err := h.db.QueryRow(ctx, `SELECT types FROM registers WHERE id = $1::uuid`, body.RegisterID).Scan(&types); err != nil {
return c.Status(404).JSON(fiber.Map{"error": "register does not exist"})
}
if !contains(types, body.Method) {
return c.Status(400).JSON(fiber.Map{"error": "this register does not accept method: " + body.Method})
}
}
var id string
err := h.db.QueryRow(ctx,
`INSERT INTO cash_transactions (type, method, amount, order_id, cartridge_batch_id, register_id, category_id, note, is_pending, created_by_staff_id, created_by_staff_name)
VALUES ($1, $2, $3::numeric, $4::uuid, $5::uuid, $9::uuid, $10::uuid, $6, $11, $7::uuid, $8)
RETURNING id`,
body.Type, body.Method, body.Amount, dbutil.NullIfEmpty(body.OrderID), dbutil.NullIfEmpty(body.CartridgeBatchID),
dbutil.NullIfEmpty(body.Note), auth.StaffID(c), auth.StaffName(c),
dbutil.NullIfEmpty(body.RegisterID), dbutil.NullIfEmpty(body.CategoryID), body.IsPending,
).Scan(&id)
if err != nil {
if dbutil.IsFKViolation(err) {
return c.Status(404).JSON(fiber.Map{"error": "order, cartridge batch, register, or category does not exist"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
// Best-effort, fire-and-forget — see internal/kkm's package doc. A
// receipt failure never fails the cash entry itself. A pending row isn't
// real income yet, so no receipt goes out until it's confirmed (see
// Confirm below).
if !body.IsPending {
if err := h.kkm.Send(ctx, kkm.Receipt{Type: body.Type, Amount: body.Amount, Method: body.Method, Note: body.Note}); err != nil {
log.Printf("cash: kkm receipt for transaction %s failed: %v", id, err)
}
}
return c.Status(201).JSON(fiber.Map{"id": id})
}
// Confirm flips a pending income row to confirmed once the money actually
// lands — the only way is_pending ever changes, deliberately one-directional
// (nothing un-confirms a row back to pending; that's a correction entry
// like everywhere else in this ledger, not an edit).
func (h *Handler) Confirm(c *fiber.Ctx) error {
id := c.Params("id")
ctx := context.Background()
var txType, amount, method, note string
err := h.db.QueryRow(ctx,
`UPDATE cash_transactions SET is_pending = false
WHERE id = $1::uuid AND is_pending = true
RETURNING type, amount::text, method, COALESCE(note, '')`,
id,
).Scan(&txType, &amount, &method, &note)
if err != nil {
if err == pgx.ErrNoRows {
return c.Status(404).JSON(fiber.Map{"error": "pending transaction not found"})
}
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if err := h.kkm.Send(ctx, kkm.Receipt{Type: txType, Amount: amount, Method: method, Note: note}); err != nil {
log.Printf("cash: kkm receipt for confirmed transaction %s failed: %v", id, err)
}
return c.JSON(fiber.Map{"ok": true})
}
// ExpectedFromReadyOrders sums final_price (falling back to price_estimate
// when the final figure isn't set yet) across every order currently sitting
// in a "ready for pickup" status — repaired, sitting on the shelf, nobody's
// paid for it yet. Plain total, not netted against any partial/prepayment
// already recorded for those orders — an owner-facing "how much should
// still come in from what's already done" figure, not a reconciled AR
// balance.
func (h *Handler) ExpectedFromReadyOrders(c *fiber.Ctx) error {
var count int
var amount string
err := h.db.QueryRow(context.Background(),
`SELECT COUNT(*), COALESCE(SUM(COALESCE(o.final_price, o.price_estimate)), 0)::text
FROM orders o JOIN order_statuses os ON os.key = o.status
WHERE os.system_role = 'ready'`,
).Scan(&count, &amount)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"count": count, "amount": amount})
}
func contains(list []string, v string) bool {
for _, item := range list {
if item == v {
return true
}
}
return false
}
type transactionRow struct {
ID string `json:"id"`
Type string `json:"type"`
Method string `json:"method"`
Amount string `json:"amount"`
OrderID *string `json:"order_id"`
CartridgeBatchID *string `json:"cartridge_batch_id"`
RegisterID *string `json:"register_id"`
CategoryID *string `json:"category_id"`
TransferPairID *string `json:"transfer_pair_id"`
Note *string `json:"note"`
IsPending bool `json:"is_pending"`
StaffName string `json:"staff_name"`
CreatedAt time.Time `json:"created_at"`
}
const transactionColumns = `id, type, method, amount::text, order_id, cartridge_batch_id, register_id, category_id, transfer_pair_id, note, is_pending, created_by_staff_name, created_at`
func scanTransactionRow(row pgx.Row) (transactionRow, error) {
var r transactionRow
err := row.Scan(&r.ID, &r.Type, &r.Method, &r.Amount, &r.OrderID, &r.CartridgeBatchID,
&r.RegisterID, &r.CategoryID, &r.TransferPairID, &r.Note, &r.IsPending, &r.StaffName, &r.CreatedAt)
return r, err
}
// List supports ?type=, ?order_id=, ?cartridge_batch_id=, ?register_id=,
// and a ?from=/?to= (RFC3339) date range — the filter set a "Касса" page
// needs, plus the two sub-resource listings (ListForOrder/
// ListForCartridgeBatch below) reuse it with order_id/cartridge_batch_id
// pre-set.
func (h *Handler) List(c *fiber.Ctx) error {
return h.list(c, c.Query("type"), c.Query("order_id"), c.Query("cartridge_batch_id"))
}
// ListForOrder/ListForCartridgeBatch are reachable by any staff role
// (unlike List/Create/Summary, mounted behind cashOnly in main.go) since a
// master legitimately needs to see the parts/payment history on their own
// job — but that means, unlike the owner/manager-only endpoints, these two
// need their own ownership check so a master can't pull another master's
// order's transaction history through them.
func (h *Handler) ListForOrder(c *fiber.Ctx) error {
orderID := c.Params("id")
if err := authz.CheckOrderAccess(context.Background(), h.db, orderID, auth.StaffPermissions(c), auth.StaffID(c)); err != nil {
return err
}
return h.list(c, "", orderID, "")
}
func (h *Handler) ListForCartridgeBatch(c *fiber.Ctx) error {
batchID := c.Params("id")
if err := authz.CheckBatchAccess(context.Background(), h.db, batchID, auth.StaffPermissions(c), auth.StaffID(c)); err != nil {
return err
}
return h.list(c, "", "", batchID)
}
func (h *Handler) list(c *fiber.Ctx, txType, orderID, cartridgeBatchID string) error {
from := c.Query("from")
to := c.Query("to")
scope := c.Query("scope")
registerID := c.Query("register_id")
method := c.Query("method")
pending := c.Query("pending")
// order_id/cartridge_batch_id/register_id compared as ::text (like
// order.List's assigned_master_id::text = $2), not cast to ::uuid, so a
// malformed id just matches nothing instead of failing the query —
// from/to still cast to ::timestamptz since there's no equivalent safe
// text comparison for a date range, so those two rely on the rows.Err()
// check below.
// scope=shop/service splits the ledger by whether sale_id is set (POS
// checkout vs everything else) — the "Магазин"/"Сервис" sidebar mode
// from Phase 9 passes it through so each mode's Касса page only shows
// its own transactions without a parallel table.
rows, err := h.db.Query(context.Background(),
`SELECT `+transactionColumns+` FROM cash_transactions
WHERE ($1 = '' OR type = $1)
AND ($2 = '' OR order_id::text = $2)
AND ($3 = '' OR cartridge_batch_id::text = $3)
AND ($4 = '' OR created_at >= $4::timestamptz)
AND ($5 = '' OR created_at < $5::timestamptz)
AND ($6 = '' OR ($6 = 'shop' AND sale_id IS NOT NULL) OR ($6 = 'service' AND sale_id IS NULL))
AND ($7 = '' OR register_id::text = $7)
AND ($8 = '' OR method = $8)
AND ($9 = '' OR is_pending = ($9 = 'true'))
ORDER BY created_at DESC LIMIT 500`,
txType, orderID, cartridgeBatchID, from, to, scope, registerID, method, pending)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer rows.Close()
out := []transactionRow{}
for rows.Next() {
r, err := scanTransactionRow(rows)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
out = append(out, r)
}
// Query() can return successfully even though the statement later fails
// during execution (e.g. a malformed from/to that fails the ::timestamptz
// cast) — that error only surfaces via rows.Err() after the loop, not
// via the earlier err from Query() itself. Without this check, a bad
// filter silently returned 200 [] instead of erroring — confirmed live
// against the running stack before this fix.
if err := rows.Err(); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(out)
}
// Summary totals income/expense/payroll for a ?from=/?to= date range (both
// optional). Net is left for the caller to compute for display — this
// returns the three components as decimal strings, not a rounded/derived
// figure, so it stays a display summary rather than something reporting
// code would treat as authoritative.
func (h *Handler) Summary(c *fiber.Ctx) error {
from := c.Query("from")
to := c.Query("to")
scope := c.Query("scope")
var income, expense, payroll string
err := h.db.QueryRow(context.Background(),
`SELECT
COALESCE(SUM(amount) FILTER (WHERE type = 'income'), 0)::text,
COALESCE(SUM(amount) FILTER (WHERE type = 'expense'), 0)::text,
COALESCE(SUM(amount) FILTER (WHERE type = 'payroll'), 0)::text
FROM cash_transactions
WHERE NOT is_pending
AND ($1 = '' OR created_at >= $1::timestamptz) AND ($2 = '' OR created_at < $2::timestamptz)
AND ($3 = '' OR ($3 = 'shop' AND sale_id IS NOT NULL) OR ($3 = 'service' AND sale_id IS NULL))`,
from, to, scope,
).Scan(&income, &expense, &payroll)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{"income": income, "expense": expense, "payroll": payroll})
}