354 lines
14 KiB
Go
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, ¬e)
|
|
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})
|
|
}
|