package payments

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	"github.com/google/uuid"
)

// myNalogLockKey is the advisory-lock key guarding an export run. One run at a time is a hard
// requirement, not a nicety: the tax service accepts no idempotency key, so two overlapping runs
// could file the same income twice.
const myNalogLockKey int64 = 0x6D796E616C6F67 // "mynalog"

// taxableIncome is the predicate selecting ledger rows this export is responsible for: rouble
// funding on the direct rail. Store rails are excluded because the ledger records their price in
// the store's own currency and holds no rouble amount at all.
const taxableIncome = `l.kind = 'fund' AND l.provider = $1 AND l.order_id IS NOT NULL
	AND l.snapshot->>'currency' = $2`

// refundedOrder matches an income whose order has since been refunded.
const refundedOrder = `EXISTS (SELECT 1 FROM payments.ledger x
	WHERE x.order_id = l.order_id AND x.kind = 'refund')`

// incomeColumns is the projection every queue row is read through.
const incomeColumns = `l.ledger_id, l.order_id, l.account_id, l.snapshot, l.created_at,
	COALESCE(r.status, ''), COALESCE(r.receipt_name, ''), COALESCE(r.receipt_uuid, ''),
	COALESCE(r.attempts, 0), COALESCE(r.last_error, '')`

// myNalogQueue reads everything one export run must consider. It is four cheap reads against the
// ledger and the export table; nothing here touches the tax service, which is what lets the engine
// decide it has no work before authenticating.
func (s *Store) myNalogQueue(ctx context.Context, limit int) (MyNalogQueue, error) {
	var q MyNalogQueue
	var err error

	// Owed: never attempted, or attempted and understood to have failed — and not since refunded.
	if q.Incomes, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
		AND NOT `+refundedOrder, limit); err != nil {
		return MyNalogQueue{}, err
	}
	// Moot: refunded before a receipt was ever issued, so nothing is filed and nothing is annulled.
	if q.NotRequired, err = s.myNalogIncomes(ctx, `(r.status IS NULL OR r.status = '`+MyNalogFailed+`')
		AND `+refundedOrder, limit); err != nil {
		return MyNalogQueue{}, err
	}
	// Stuck: an outcome nobody can infer. These halt the run and wait for a human.
	if q.Attention, err = s.myNalogIncomes(ctx,
		`r.status IN ('`+MyNalogUnknown+`', '`+MyNalogSending+`')`, limit); err != nil {
		return MyNalogQueue{}, err
	}
	if q.Cancels, err = s.myNalogCancels(ctx, limit); err != nil {
		return MyNalogQueue{}, err
	}
	return q, nil
}

// myNalogIncomes reads taxable incomes narrowed by an extra predicate over the export row, oldest
// first — the oldest income is the one closest to its filing deadline.
func (s *Store) myNalogIncomes(ctx context.Context, extra string, limit int) ([]MyNalogIncome, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT `+incomeColumns+`
		FROM payments.ledger l
		LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
		WHERE `+taxableIncome+` AND (`+extra+`)
		ORDER BY l.created_at
		LIMIT $3`, myNalogProvider, string(CurrencyRUB), limit)
	if err != nil {
		return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
	}
	defer rows.Close()

	var out []MyNalogIncome
	for rows.Next() {
		var (
			in       MyNalogIncome
			orderID  uuid.NullUUID
			snapshot sql.NullString
		)
		if err := rows.Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
			&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError); err != nil {
			return nil, fmt.Errorf("payments: scan mynalog queue row: %w", err)
		}
		in.OrderID = orderID.UUID
		in.Amount = moneyFromSnapshot(snapshot.String)
		in.Title, in.Chips = describeSnapshot(snapshot.String)
		out = append(out, in)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("payments: read mynalog queue: %w", err)
	}
	return out, nil
}

// myNalogIncomeByLedger reads one taxable income by its ledger row, reporting false when that row
// is not one this export is responsible for.
func (s *Store) myNalogIncomeByLedger(ctx context.Context, ledgerID uuid.UUID) (MyNalogIncome, bool, error) {
	var (
		in       MyNalogIncome
		orderID  uuid.NullUUID
		snapshot sql.NullString
	)
	err := s.db.QueryRowContext(ctx, `
		SELECT `+incomeColumns+`
		FROM payments.ledger l
		LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
		WHERE `+taxableIncome+` AND l.ledger_id = $3`,
		myNalogProvider, string(CurrencyRUB), ledgerID).
		Scan(&in.LedgerID, &orderID, &in.AccountID, &snapshot, &in.OperationTime,
			&in.Status, &in.ReceiptName, &in.ReceiptUUID, &in.Attempts, &in.LastError)
	if err == sql.ErrNoRows {
		return MyNalogIncome{}, false, nil
	}
	if err != nil {
		return MyNalogIncome{}, false, fmt.Errorf("payments: read mynalog income: %w", err)
	}
	in.OrderID = orderID.UUID
	in.Amount = moneyFromSnapshot(snapshot.String)
	in.Title, in.Chips = describeSnapshot(snapshot.String)
	return in, true, nil
}

// myNalogCancels reads receipts whose income has been refunded and which are therefore owed an
// annulment, oldest refund first. A previously failed annulment is included: the receipt still
// stands, so it is still owed.
func (s *Store) myNalogCancels(ctx context.Context, limit int) ([]MyNalogCancel, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT r.ledger_id, x.ledger_id, r.order_id, r.account_id, r.receipt_uuid,
		       r.cancel_status, r.last_error
		FROM payments.mynalog_receipt r
		JOIN payments.ledger x ON x.order_id = r.order_id AND x.kind = 'refund'
		WHERE r.status = $1 AND r.cancel_status IN ($2, $3)
		ORDER BY x.created_at
		LIMIT $4`, MyNalogSent, MyNalogCancelNone, MyNalogCancelFailed, limit)
	if err != nil {
		return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
	}
	defer rows.Close()

	var out []MyNalogCancel
	for rows.Next() {
		var c MyNalogCancel
		if err := rows.Scan(&c.LedgerID, &c.RefundLedgerID, &c.OrderID, &c.AccountID, &c.ReceiptUUID,
			&c.CancelStatus, &c.LastError); err != nil {
			return nil, fmt.Errorf("payments: scan mynalog cancel: %w", err)
		}
		out = append(out, c)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("payments: read mynalog cancels: %w", err)
	}
	return out, nil
}

// myNalogBacklogBefore counts and sums taxable income received before the cut-off that has neither
// been registered nor been ruled out.
func (s *Store) myNalogBacklogBefore(ctx context.Context, before time.Time) (MyNalogBacklog, error) {
	var (
		out    MyNalogBacklog
		oldest sql.NullTime
	)
	err := s.db.QueryRowContext(ctx, `
		SELECT count(*),
		       COALESCE(SUM((l.snapshot->>'amount_minor')::bigint), 0),
		       MIN(l.created_at)
		FROM payments.ledger l
		LEFT JOIN payments.mynalog_receipt r ON r.ledger_id = l.ledger_id
		WHERE `+taxableIncome+`
		  AND l.created_at < $3
		  AND (r.status IS NULL OR r.status NOT IN ($4, $5))`,
		myNalogProvider, string(CurrencyRUB), before, MyNalogSent, MyNalogNotRequired,
	).Scan(&out.Count, &out.AmountMinor, &oldest)
	if err != nil {
		return MyNalogBacklog{}, fmt.Errorf("payments: read mynalog backlog: %w", err)
	}
	out.Oldest = oldest.Time
	return out, nil
}

// promoteStrandedSending reclassifies rows left mid-call by a process that died. The caller holds
// the export lock, so nothing is genuinely in flight; and because the registration call is not
// idempotent, the safe reading of "we started and do not know how it ended" is MyNalogUnknown,
// never "try again".
func (s *Store) promoteStrandedSending(ctx context.Context, now time.Time) (int, error) {
	res, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_receipt
		SET status = $1, last_error = $2, updated_at = $3
		WHERE status = $4`,
		MyNalogUnknown, "the export stopped mid-call; the outcome was never established",
		now, MyNalogSending)
	if err != nil {
		return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return 0, fmt.Errorf("payments: promote stranded mynalog rows: %w", err)
	}
	return int(n), nil
}

// beginMyNalogSend writes the intent to register, freezing the receipt name. It commits before the
// request leaves so that a crash mid-call is visible afterwards.
func (s *Store) beginMyNalogSend(ctx context.Context, in MyNalogIncome, receiptName string, now time.Time) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
			operation_time, amount_minor, currency, attempts, created_at, updated_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9, $9)
		ON CONFLICT (ledger_id) DO UPDATE SET
			status = EXCLUDED.status,
			receipt_name = EXCLUDED.receipt_name,
			attempts = payments.mynalog_receipt.attempts + 1,
			last_error = '',
			updated_at = EXCLUDED.updated_at`,
		in.LedgerID, in.OrderID, in.AccountID, MyNalogSending, receiptName,
		in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
	if err != nil {
		return fmt.Errorf("payments: begin mynalog send: %w", err)
	}
	return nil
}

// markMyNalogSent records the receipt the tax service issued.
func (s *Store) markMyNalogSent(ctx context.Context, ledgerID uuid.UUID, receiptUUID string, manual bool, now time.Time) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_receipt
		SET status = $1, receipt_uuid = $2, entered_manually = $3, sent_at = $4,
		    last_error = '', updated_at = $4
		WHERE ledger_id = $5`,
		MyNalogSent, receiptUUID, manual, now, ledgerID)
	if err != nil {
		return fmt.Errorf("payments: mark mynalog sent: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("payments: mark mynalog sent: no export row for ledger %s", ledgerID)
	}
	return nil
}

// markMyNalogManual records an income an operator filed by hand, creating the row when the export
// never attempted it. Such a row is deliberately indistinguishable from an automatic one apart from
// the flag, so a later refund still annuls the receipt without further human involvement.
func (s *Store) markMyNalogManual(ctx context.Context, in MyNalogIncome, receiptName, receiptUUID string, now time.Time) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_uuid,
			receipt_name, operation_time, amount_minor, currency, entered_manually, sent_at,
			created_at, updated_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10, $10, $10)
		ON CONFLICT (ledger_id) DO UPDATE SET
			status = EXCLUDED.status,
			receipt_uuid = EXCLUDED.receipt_uuid,
			entered_manually = true,
			sent_at = EXCLUDED.sent_at,
			last_error = '',
			updated_at = EXCLUDED.updated_at`,
		in.LedgerID, in.OrderID, in.AccountID, MyNalogSent, receiptUUID, receiptName,
		in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), now)
	if err != nil {
		return fmt.Errorf("payments: mark mynalog manual: %w", err)
	}
	return nil
}

// markMyNalogOutcome records a non-final outcome and why.
func (s *Store) markMyNalogOutcome(ctx context.Context, ledgerID uuid.UUID, status, reason string, now time.Time) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_receipt
		SET status = $1, last_error = $2, updated_at = $3
		WHERE ledger_id = $4`, status, reason, now, ledgerID)
	if err != nil {
		return fmt.Errorf("payments: mark mynalog outcome: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("payments: mark mynalog outcome: no export row for ledger %s", ledgerID)
	}
	return nil
}

// markMyNalogNotRequired records that an income needs no receipt, because the buyer had the money
// back before one was issued.
func (s *Store) markMyNalogNotRequired(ctx context.Context, in MyNalogIncome, reason string, now time.Time) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO payments.mynalog_receipt (ledger_id, order_id, account_id, status, receipt_name,
			operation_time, amount_minor, currency, last_error, created_at, updated_at)
		VALUES ($1, $2, $3, $4, '', $5, $6, $7, $8, $9, $9)
		ON CONFLICT (ledger_id) DO UPDATE SET
			status = EXCLUDED.status,
			last_error = EXCLUDED.last_error,
			updated_at = EXCLUDED.updated_at`,
		in.LedgerID, in.OrderID, in.AccountID, MyNalogNotRequired,
		in.OperationTime, in.Amount.Minor(), string(in.Amount.Currency()), reason, now)
	if err != nil {
		return fmt.Errorf("payments: mark mynalog not required: %w", err)
	}
	return nil
}

// markMyNalogCancel records the outcome of annulling a receipt.
func (s *Store) markMyNalogCancel(ctx context.Context, ledgerID, refundLedgerID uuid.UUID, status, reason string, now time.Time) error {
	var cancelledAt any
	if status == MyNalogCancelled {
		cancelledAt = now
	}
	res, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_receipt
		SET cancel_status = $1, cancel_refund_ledger_id = $2, cancelled_at = $3,
		    last_error = $4, updated_at = $5
		WHERE ledger_id = $6`, status, refundLedgerID, cancelledAt, reason, now, ledgerID)
	if err != nil {
		return fmt.Errorf("payments: mark mynalog cancel: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("payments: mark mynalog cancel: no export row for ledger %s", ledgerID)
	}
	return nil
}

// saveMyNalogSession stores the session. Signing in again clears a self-imposed pause — that is the
// operator telling us the cause has been dealt with — while the automatic mode is left as it was,
// so a routine re-login does not silently arm or disarm it.
func (s *Store) saveMyNalogSession(ctx context.Context, inn, deviceID string, refreshTokenEnc []byte, now time.Time) error {
	_, err := s.db.ExecContext(ctx, `
		INSERT INTO payments.mynalog_session (only_row, inn, device_id, refresh_token_enc, updated_at)
		VALUES (true, $1, $2, $3, $4)
		ON CONFLICT (only_row) DO UPDATE SET
			inn = EXCLUDED.inn,
			device_id = EXCLUDED.device_id,
			refresh_token_enc = EXCLUDED.refresh_token_enc,
			paused_at = NULL,
			pause_reason = '',
			updated_at = EXCLUDED.updated_at`,
		inn, deviceID, refreshTokenEnc, now)
	if err != nil {
		return fmt.Errorf("payments: save mynalog session: %w", err)
	}
	return nil
}

// updateMyNalogRefreshToken replaces the stored token after the service rotated it.
func (s *Store) updateMyNalogRefreshToken(ctx context.Context, refreshTokenEnc []byte, now time.Time) error {
	_, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET refresh_token_enc = $1, updated_at = $2
		WHERE only_row`, refreshTokenEnc, now)
	if err != nil {
		return fmt.Errorf("payments: update mynalog refresh token: %w", err)
	}
	return nil
}

// myNalogSession reads the stored session.
func (s *Store) myNalogSession(ctx context.Context) (MyNalogSession, bool, error) {
	var (
		out                             MyNalogSession
		pausedAt, lastOKAt, lastAlertAt sql.NullTime
	)
	err := s.db.QueryRowContext(ctx, `
		SELECT inn, device_id, refresh_token_enc, auto_enabled, paused_at, pause_reason,
		       last_ok_at, last_alert_at, last_alert_kind, updated_at
		FROM payments.mynalog_session WHERE only_row`).
		Scan(&out.INN, &out.DeviceID, &out.RefreshTokenEnc, &out.AutoEnabled, &pausedAt,
			&out.PauseReason, &lastOKAt, &lastAlertAt, &out.LastAlertKind, &out.UpdatedAt)
	if err == sql.ErrNoRows {
		return MyNalogSession{}, false, nil
	}
	if err != nil {
		return MyNalogSession{}, false, fmt.Errorf("payments: read mynalog session: %w", err)
	}
	if pausedAt.Valid {
		out.PausedAt = &pausedAt.Time
	}
	if lastOKAt.Valid {
		out.LastOKAt = &lastOKAt.Time
	}
	if lastAlertAt.Valid {
		out.LastAlertAt = &lastAlertAt.Time
	}
	return out, true, nil
}

// dropMyNalogSession signs the rail out.
func (s *Store) dropMyNalogSession(ctx context.Context) error {
	if _, err := s.db.ExecContext(ctx, `DELETE FROM payments.mynalog_session WHERE only_row`); err != nil {
		return fmt.Errorf("payments: drop mynalog session: %w", err)
	}
	return nil
}

// setMyNalogAuto arms or disarms the automatic export.
func (s *Store) setMyNalogAuto(ctx context.Context, enabled bool, now time.Time) error {
	res, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET auto_enabled = $1, updated_at = $2
		WHERE only_row`, enabled, now)
	if err != nil {
		return fmt.Errorf("payments: set mynalog auto: %w", err)
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("payments: set mynalog auto: sign in first")
	}
	return nil
}

// pauseMyNalog takes the rail out of service with a reason.
func (s *Store) pauseMyNalog(ctx context.Context, reason string, now time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET paused_at = $1, pause_reason = $2, updated_at = $1
		WHERE only_row AND paused_at IS NULL`, now, reason); err != nil {
		return fmt.Errorf("payments: pause mynalog: %w", err)
	}
	return nil
}

// resumeMyNalog clears a self-imposed pause.
func (s *Store) resumeMyNalog(ctx context.Context, now time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET paused_at = NULL, pause_reason = '', updated_at = $1
		WHERE only_row`, now); err != nil {
		return fmt.Errorf("payments: resume mynalog: %w", err)
	}
	return nil
}

// noteMyNalogOK records a successful exchange with the tax service.
func (s *Store) noteMyNalogOK(ctx context.Context, now time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET last_ok_at = $1, updated_at = $1
		WHERE only_row`, now); err != nil {
		return fmt.Errorf("payments: note mynalog ok: %w", err)
	}
	return nil
}

// noteMyNalogAlert records that an alert of the given kind went out.
func (s *Store) noteMyNalogAlert(ctx context.Context, kind string, now time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
		UPDATE payments.mynalog_session SET last_alert_at = $1, last_alert_kind = $2, updated_at = $1
		WHERE only_row`, now, kind); err != nil {
		return fmt.Errorf("payments: note mynalog alert: %w", err)
	}
	return nil
}

// tryLockMyNalog takes the advisory lock guarding an export run.
//
// The lock is held on one pinned connection, because a session-level advisory lock belongs to the
// connection that took it: releasing it from another pooled connection would silently fail and
// leave the rail locked until the process restarted.
func (s *Store) tryLockMyNalog(ctx context.Context) (func(), bool, error) {
	conn, err := s.db.Conn(ctx)
	if err != nil {
		return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
	}
	var locked bool
	if err := conn.QueryRowContext(ctx, `SELECT pg_try_advisory_lock($1)`, myNalogLockKey).Scan(&locked); err != nil {
		_ = conn.Close()
		return nil, false, fmt.Errorf("payments: lock mynalog: %w", err)
	}
	if !locked {
		_ = conn.Close()
		return nil, false, nil
	}
	release := func() {
		// The caller's context is typically done by now (that is what ended the run), and the lock
		// outlives the connection's return to the pool, so the unlock must not inherit that
		// cancellation.
		_, _ = conn.ExecContext(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, myNalogLockKey)
		_ = conn.Close()
	}
	return release, true, nil
}

// pendingPurchaseMails reads credited purchases whose buyer has not been written to yet. Every
// undecided succeeded event is returned, including store-rail ones the caller will merely stamp:
// the cursor tracks the decision, so leaving a row unexamined would mean revisiting it forever.
func (s *Store) pendingPurchaseMails(ctx context.Context, limit int) ([]PurchaseMail, error) {
	rows, err := s.db.QueryContext(ctx, `
		SELECT e.event_id, e.account_id, e.order_id, COALESCE(o.origin, ''),
		       COALESCE(o.expected_amount, 0), COALESCE(o.currency, ''),
		       COALESCE(p.title, ''), e.created_at
		FROM payments.payment_events e
		LEFT JOIN payments.orders o ON o.order_id = e.order_id
		LEFT JOIN payments.product p ON p.product_id = o.product_id
		WHERE e.type = 'succeeded' AND e.mailed_at IS NULL
		ORDER BY e.created_at
		LIMIT $1`, limit)
	if err != nil {
		return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
	}
	defer rows.Close()

	var out []PurchaseMail
	for rows.Next() {
		var (
			m       PurchaseMail
			orderID uuid.NullUUID
		)
		if err := rows.Scan(&m.EventID, &m.AccountID, &orderID, &m.Origin, &m.AmountMinor,
			&m.Currency, &m.Title, &m.CreatedAt); err != nil {
			return nil, fmt.Errorf("payments: scan purchase mail: %w", err)
		}
		m.OrderID = orderID.UUID
		out = append(out, m)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("payments: read purchase mail queue: %w", err)
	}
	return out, nil
}

// markPurchaseMailed stamps the mail decision for an event.
func (s *Store) markPurchaseMailed(ctx context.Context, eventID uuid.UUID, now time.Time) error {
	if _, err := s.db.ExecContext(ctx, `
		UPDATE payments.payment_events SET mailed_at = $1 WHERE event_id = $2`,
		now, eventID); err != nil {
		return fmt.Errorf("payments: mark purchase mailed: %w", err)
	}
	return nil
}

// pendingMyNalogMails reads receipts owed a letter: the annulment letter when cancelled is true,
// the receipt letter otherwise. The product title and pack size come from the ledger snapshot the
// income was filed from, so the letter describes what the buyer actually bought.
func (s *Store) pendingMyNalogMails(ctx context.Context, limit int, cancelled bool) ([]ReceiptMail, error) {
	// Both variants bind the same two parameters, so neither leaves a placeholder unreferenced. A
	// cancelled receipt is always a sent one, so repeating the status test costs nothing.
	where := `r.status = $1 AND r.receipt_mail_sent_at IS NULL`
	if cancelled {
		where = `r.status = $1 AND r.cancel_status = '` + MyNalogCancelled +
			`' AND r.cancel_mail_sent_at IS NULL`
	}
	rows, err := s.db.QueryContext(ctx, `
		SELECT r.ledger_id, r.account_id, r.receipt_uuid, l.snapshot,
		       r.amount_minor, r.currency, r.operation_time
		FROM payments.mynalog_receipt r
		JOIN payments.ledger l ON l.ledger_id = r.ledger_id
		WHERE `+where+`
		ORDER BY r.updated_at
		LIMIT $2`, MyNalogSent, limit)
	if err != nil {
		return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
	}
	defer rows.Close()

	var out []ReceiptMail
	for rows.Next() {
		var (
			m        ReceiptMail
			snapshot sql.NullString
		)
		if err := rows.Scan(&m.LedgerID, &m.AccountID, &m.ReceiptUUID, &snapshot,
			&m.AmountMinor, &m.Currency, &m.OperationTime); err != nil {
			return nil, fmt.Errorf("payments: scan receipt mail: %w", err)
		}
		m.Title, _ = describeSnapshot(snapshot.String)
		out = append(out, m)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("payments: read receipt mail queue: %w", err)
	}
	return out, nil
}

// markMyNalogMailed stamps the letter decision for an income.
func (s *Store) markMyNalogMailed(ctx context.Context, ledgerID uuid.UUID, cancelled bool, now time.Time) error {
	column := "receipt_mail_sent_at"
	if cancelled {
		column = "cancel_mail_sent_at"
	}
	if _, err := s.db.ExecContext(ctx,
		`UPDATE payments.mynalog_receipt SET `+column+` = $1, updated_at = $1 WHERE ledger_id = $2`,
		now, ledgerID); err != nil {
		return fmt.Errorf("payments: mark receipt mailed: %w", err)
	}
	return nil
}
