felt/internal/financial/receipt.go
Mikkel Georgsen 56a7ef1e31 docs(01-13): complete Layout Shell plan
- 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>
2026-03-01 04:15:37 +01:00

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, &currencyCode, &currencySymbol)
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
}