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

148 lines
5.9 KiB
Go

package cash
import (
"context"
"math"
"strconv"
"unicode/utf8"
"production/internal/auth"
"github.com/gofiber/fiber/v2"
)
type transferInput struct {
FromRegisterID string `json:"from_register_id"`
ToRegisterID string `json:"to_register_id"`
Method string `json:"method"`
Amount string `json:"amount"`
Note string `json:"note"`
}
func (b transferInput) validate() string {
if b.FromRegisterID == "" || b.ToRegisterID == "" {
return "from_register_id and to_register_id are required"
}
if b.FromRegisterID == b.ToRegisterID {
return "from_register_id and to_register_id must differ"
}
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"
}
amt, err := strconv.ParseFloat(b.Amount, 64)
if err != nil || amt <= 0 || math.IsNaN(amt) || math.IsInf(amt, 0) || amt > maxAmount {
return "amount must be a positive number, at most " + strconv.FormatFloat(maxAmount, 'f', 2, 64)
}
if utf8.RuneCountInString(b.Note) > maxLongFieldLen {
return "note is too long"
}
return ""
}
// Transfer moves money between two registers as one logical operation —
// two linked 'transfer' rows, atomic in one DB transaction so a mid-way
// failure never leaves one leg without its pair. The source leg is stored
// negative and the destination positive (same physical amount, opposite
// sign) rather than both positive with direction implied by which column
// is which — that's what lets ListRegisters compute every register's
// balance with one plain SUM(amount), transfer included, no special-casing
// for which side of a transfer a row is on. 'transfer' is deliberately its
// own type (not income/expense) so cash.Summary's totals — which filter by
// type — never count an internal reallocation as revenue or a real
// expense.
func (h *Handler) Transfer(c *fiber.Ctx) error {
var body transferInput
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})
}
amt, _ := strconv.ParseFloat(body.Amount, 64)
negAmount := strconv.FormatFloat(-amt, 'f', 2, 64)
posAmount := strconv.FormatFloat(amt, 'f', 2, 64)
ctx := context.Background()
tx, err := h.db.Begin(ctx)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
defer tx.Rollback(ctx)
staffID, staffName := auth.StaffID(c), auth.StaffName(c)
note := "Перевод между кассами"
if body.Note != "" {
note = body.Note
}
// FOR UPDATE on the source register serializes concurrent transfers/
// expenses against it, so the balance check just below can't race with
// another request draining the same register between the check and the
// insert.
var fromTypes []string
if err := tx.QueryRow(ctx, `SELECT types FROM registers WHERE id = $1::uuid FOR UPDATE`, body.FromRegisterID).Scan(&fromTypes); err != nil {
return c.Status(404).JSON(fiber.Map{"error": "from_register_id does not exist"})
}
var toTypes []string
if err := tx.QueryRow(ctx, `SELECT types FROM registers WHERE id = $1::uuid`, body.ToRegisterID).Scan(&toTypes); err != nil {
return c.Status(404).JSON(fiber.Map{"error": "to_register_id does not exist"})
}
if !contains(fromTypes, body.Method) {
return c.Status(400).JSON(fiber.Map{"error": "from_register_id does not accept method: " + body.Method})
}
if !contains(toTypes, body.Method) {
return c.Status(400).JSON(fiber.Map{"error": "to_register_id does not accept method: " + body.Method})
}
// Same balance formula as ListRegisters (registers.go), scoped to this
// one method within the source register — a transfer must not be able
// to push that specific type's balance negative, even if the register's
// other types are well in the black. Reads inside the same transaction
// that holds the row lock above, so this figure can't go stale before
// the insert below commits.
var fromBalance float64
if err := tx.QueryRow(ctx,
`SELECT COALESCE(SUM(amount) FILTER (WHERE type = 'income'), 0)
- COALESCE(SUM(amount) FILTER (WHERE type = 'expense'), 0)
- COALESCE(SUM(amount) FILTER (WHERE type = 'payroll'), 0)
+ COALESCE(SUM(amount) FILTER (WHERE type = 'transfer'), 0)
FROM cash_transactions WHERE register_id = $1::uuid AND method = $2 AND NOT is_pending`,
body.FromRegisterID, body.Method,
).Scan(&fromBalance); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if fromBalance < amt {
return c.Status(400).JSON(fiber.Map{"error": "insufficient balance in from_register_id"})
}
var sourceID, destID string
if err := tx.QueryRow(ctx,
`INSERT INTO cash_transactions (type, method, amount, register_id, note, created_by_staff_id, created_by_staff_name)
VALUES ('transfer', $1, $2::numeric, $3::uuid, $4, $5::uuid, $6) RETURNING id`,
body.Method, negAmount, body.FromRegisterID, note, staffID, staffName,
).Scan(&sourceID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if err := tx.QueryRow(ctx,
`INSERT INTO cash_transactions (type, method, amount, register_id, note, transfer_pair_id, created_by_staff_id, created_by_staff_name)
VALUES ('transfer', $1, $2::numeric, $3::uuid, $4, $5::uuid, $6::uuid, $7) RETURNING id`,
body.Method, posAmount, body.ToRegisterID, note, sourceID, staffID, staffName,
).Scan(&destID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if _, err := tx.Exec(ctx, `UPDATE cash_transactions SET transfer_pair_id = $1::uuid WHERE id = $2::uuid`, destID, sourceID); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
if err := tx.Commit(ctx); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.Status(201).JSON(fiber.Map{"from_transaction_id": sourceID, "to_transaction_id": destID})
}