- SUMMARY.md with all accomplishments and deviation documentation - STATE.md updated: plan 8/14, 50% progress, decisions, session - ROADMAP.md updated: 7/14 plans complete - REQUIREMENTS.md: UI-01 through UI-04, UI-07, UI-08 marked complete Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
200 lines
6.2 KiB
Go
200 lines
6.2 KiB
Go
package financial
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Receipt represents a financial transaction receipt.
|
|
type Receipt struct {
|
|
ID string `json:"id"` // Same as transaction ID
|
|
TournamentID string `json:"tournament_id"`
|
|
TournamentName string `json:"tournament_name"`
|
|
VenueName string `json:"venue_name"`
|
|
CurrencyCode string `json:"currency_code"`
|
|
CurrencySymbol string `json:"currency_symbol"`
|
|
PlayerID string `json:"player_id"`
|
|
PlayerName string `json:"player_name"`
|
|
TransactionType string `json:"transaction_type"`
|
|
Amount int64 `json:"amount"` // cents
|
|
ChipsReceived int64 `json:"chips_received"`
|
|
ReceiptNumber int `json:"receipt_number"` // Sequential per tournament
|
|
Timestamp int64 `json:"timestamp"` // Unix seconds
|
|
OperatorID string `json:"operator_id"`
|
|
OperatorName string `json:"operator_name"`
|
|
|
|
// Running totals for this player in this tournament
|
|
PlayerTotalBuyIns int `json:"player_total_buyins"`
|
|
PlayerTotalRebuys int `json:"player_total_rebuys"`
|
|
PlayerTotalAddOns int `json:"player_total_addons"`
|
|
|
|
// Reprint tracking
|
|
OriginalTimestamp int64 `json:"original_timestamp,omitempty"` // For reprints
|
|
IsReprint bool `json:"is_reprint"`
|
|
}
|
|
|
|
// GenerateReceipt creates a receipt for a given transaction.
|
|
func (e *Engine) GenerateReceipt(ctx context.Context, transaction *Transaction) (*Receipt, error) {
|
|
// Get tournament name
|
|
var tournamentName string
|
|
err := e.db.QueryRowContext(ctx,
|
|
"SELECT name FROM tournaments WHERE id = ?",
|
|
transaction.TournamentID,
|
|
).Scan(&tournamentName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("financial: load tournament name: %w", err)
|
|
}
|
|
|
|
// Get venue info
|
|
venueName, currencyCode, currencySymbol := e.loadVenueInfo(ctx)
|
|
|
|
// Get player name
|
|
var playerName string
|
|
err = e.db.QueryRowContext(ctx,
|
|
"SELECT name FROM players WHERE id = ?",
|
|
transaction.PlayerID,
|
|
).Scan(&playerName)
|
|
if err != nil {
|
|
playerName = "Unknown" // Defensive
|
|
}
|
|
|
|
// Get operator name
|
|
var operatorName string
|
|
err = e.db.QueryRowContext(ctx,
|
|
"SELECT name FROM operators WHERE id = ?",
|
|
transaction.OperatorID,
|
|
).Scan(&operatorName)
|
|
if err != nil {
|
|
operatorName = transaction.OperatorID // Fallback to ID
|
|
}
|
|
|
|
// Calculate receipt number (sequential within tournament)
|
|
receiptNumber, err := e.nextReceiptNumber(ctx, transaction.TournamentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get player running totals
|
|
buyins, rebuys, addons := e.playerRunningTotals(ctx, transaction.TournamentID, transaction.PlayerID)
|
|
|
|
receipt := &Receipt{
|
|
ID: transaction.ID,
|
|
TournamentID: transaction.TournamentID,
|
|
TournamentName: tournamentName,
|
|
VenueName: venueName,
|
|
CurrencyCode: currencyCode,
|
|
CurrencySymbol: currencySymbol,
|
|
PlayerID: transaction.PlayerID,
|
|
PlayerName: playerName,
|
|
TransactionType: transaction.Type,
|
|
Amount: transaction.Amount,
|
|
ChipsReceived: transaction.Chips,
|
|
ReceiptNumber: receiptNumber,
|
|
Timestamp: time.Now().Unix(),
|
|
OperatorID: transaction.OperatorID,
|
|
OperatorName: operatorName,
|
|
PlayerTotalBuyIns: buyins,
|
|
PlayerTotalRebuys: rebuys,
|
|
PlayerTotalAddOns: addons,
|
|
}
|
|
|
|
// Store receipt data on the transaction
|
|
receiptJSON, err := json.Marshal(receipt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("financial: marshal receipt: %w", err)
|
|
}
|
|
|
|
_, err = e.db.ExecContext(ctx,
|
|
"UPDATE transactions SET receipt_data = ? WHERE id = ?",
|
|
string(receiptJSON), transaction.ID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("financial: save receipt: %w", err)
|
|
}
|
|
|
|
return receipt, nil
|
|
}
|
|
|
|
// GetReceipt retrieves a saved receipt for a transaction.
|
|
func (e *Engine) GetReceipt(ctx context.Context, transactionID string) (*Receipt, error) {
|
|
var receiptData sql.NullString
|
|
|
|
err := e.db.QueryRowContext(ctx,
|
|
"SELECT receipt_data FROM transactions WHERE id = ?",
|
|
transactionID,
|
|
).Scan(&receiptData)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrTransactionNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("financial: load receipt: %w", err)
|
|
}
|
|
|
|
if !receiptData.Valid || receiptData.String == "" {
|
|
return nil, fmt.Errorf("financial: no receipt for transaction %s", transactionID)
|
|
}
|
|
|
|
var receipt Receipt
|
|
if err := json.Unmarshal([]byte(receiptData.String), &receipt); err != nil {
|
|
return nil, fmt.Errorf("financial: parse receipt: %w", err)
|
|
}
|
|
|
|
return &receipt, nil
|
|
}
|
|
|
|
// ReprintReceipt returns the same receipt data with a new print timestamp.
|
|
func (e *Engine) ReprintReceipt(ctx context.Context, transactionID string) (*Receipt, error) {
|
|
receipt, err := e.GetReceipt(ctx, transactionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Mark as reprint with new timestamp
|
|
receipt.OriginalTimestamp = receipt.Timestamp
|
|
receipt.Timestamp = time.Now().Unix()
|
|
receipt.IsReprint = true
|
|
|
|
return receipt, nil
|
|
}
|
|
|
|
// loadVenueInfo loads the venue settings.
|
|
func (e *Engine) loadVenueInfo(ctx context.Context) (name, currencyCode, currencySymbol string) {
|
|
err := e.db.QueryRowContext(ctx,
|
|
"SELECT venue_name, currency_code, currency_symbol FROM venue_settings WHERE id = 1",
|
|
).Scan(&name, ¤cyCode, ¤cySymbol)
|
|
if err != nil {
|
|
return "Venue", "DKK", "kr" // Defaults
|
|
}
|
|
return
|
|
}
|
|
|
|
// nextReceiptNumber returns the next sequential receipt number for a tournament.
|
|
func (e *Engine) nextReceiptNumber(ctx context.Context, tournamentID string) (int, error) {
|
|
var count int
|
|
err := e.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM transactions
|
|
WHERE tournament_id = ? AND receipt_data IS NOT NULL`,
|
|
tournamentID,
|
|
).Scan(&count)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("financial: count receipts: %w", err)
|
|
}
|
|
return count + 1, nil
|
|
}
|
|
|
|
// playerRunningTotals returns the player's current transaction counts.
|
|
func (e *Engine) playerRunningTotals(ctx context.Context, tournamentID, playerID string) (buyins, rebuys, addons int) {
|
|
e.db.QueryRowContext(ctx,
|
|
`SELECT
|
|
COALESCE(SUM(CASE WHEN type = 'buyin' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN type = 'rebuy' THEN 1 ELSE 0 END), 0),
|
|
COALESCE(SUM(CASE WHEN type = 'addon' THEN 1 ELSE 0 END), 0)
|
|
FROM transactions
|
|
WHERE tournament_id = ? AND player_id = ? AND undone = 0`,
|
|
tournamentID, playerID,
|
|
).Scan(&buyins, &rebuys, &addons)
|
|
return
|
|
}
|