billing/GLOSSARY.md
Словарь
Одно значение на слово внутри контекста. Код, события, API и человек на созвоне
пишут его одинаково.
«Платёж» в трёх отделах
Отдел
Что он называет платежом
Как это называется здесь
Продукт
Покупка прошла, пользователь увидел галочку
Событие заказа. Здесь его нет
Процессинг
Деньги захолдированы, списания не было
Авторизация
Бухгалтерия
Деньги пришли выпиской через два дня
Поступление
Слова
Авторизация. Эмитент согласился держать сумму. Деньги не двигались, холд
истекает сам.
Списание. Команда забрать захолдированное. С этого момента деньги ушли со
счёта плательщика.
Поступление. Деньги дошли до нашего счёта, и это подтверждено выпиской
провайдера. Приходит днями позже и может не прийти вовсе.
Платёж. Попытка получить деньги за один заказ, со своим жизненным циклом:
авторизован, списан, возвращён. Слово значит это и ничего больше.
Слова, которых здесь нет
Покупка. Продуктовое событие: пользователь увидел галочку. Живёт в заказах,
здесь ему делать нечего.
billing/invoice/domains/invoice/invoice.go package invoice
import (
" time "
" billing/invoice/domains/invoice/events "
" billing/invoice/domains/invoice/vo "
)
// счёт: корень агрегата
type Invoice struct {
number Number
customer CustomerID
lines [] Line
total vo . Money
dueOn time . Time
status Status
facts [] events . Event
}
// создаёт счёт и считает итог из строк
func Issue (
number Number ,
customer CustomerID ,
lines [] Line ,
dueOn time . Time ,
) ( * Invoice , error ) {
if len (lines) == 0 {
return nil , ErrNoLines
}
total := vo. NewMoney ( 0 )
for _, line := range lines {
total = total. Add (line. Amount ())
}
invoice := & Invoice {
number: number,
customer: customer,
// копия строк: снаружи их не поменять
lines: append ([] Line ( nil ), lines ... ),
total: total,
dueOn: dueOn,
status: StatusIssued,
}
// записывает факт; отправлять его — не дело агрегата
invoice.facts = append (invoice.facts, events . Issued {
Number: string (number),
Customer: string (customer),
Total: total,
})
return invoice, nil
}
// отдаёт накопленные факты наружу
func ( i * Invoice ) Events () [] events . Event { return i.facts }
func ( i * Invoice ) Total () vo . Money { return i.total }
func ( i * Invoice ) DueOn () time . Time { return i.dueOn }
func ( i * Invoice ) Status () Status { return i.status }
billing/invoice/domains/invoice/paid.go package invoice
import " billing/invoice/domains/invoice/events "
// отмечает счёт оплаченным; дважды оплатить нельзя
func ( i * Invoice ) MarkPaid () error {
if i.status == StatusPaid {
return ErrAlreadyPaid
}
i.status = StatusPaid
// записывает факт; отправлять его — не дело агрегата
i.facts = append (i.facts, events . Paid {Number: string (i.number)})
return nil
}
billing/invoice/domains/invoice/charged.go package invoice
import (
" billing/invoice/domains/invoice/events "
" billing/invoice/domains/invoice/vo "
)
// приписывает пеню счёту; оплаченный счёт её не берёт
func ( i * Invoice ) ChargePenalty ( amount vo . Money ) error {
if i.status == StatusPaid {
return ErrAlreadyPaid
}
if amount. Equal (vo. NewMoney ( 0 )) {
return nil
}
i.lines = append (i.lines, Line {Quantity: 1 , Price: amount})
i.total = i.total. Add (amount)
// записывает факт; отправлять его — не дело агрегата
i.facts = append (i.facts, events . PenaltyCharged {
Number: string (i.number),
Amount: amount,
})
return nil
}
billing/invoice/domains/invoice/events/event.go package events
import " billing/invoice/domains/invoice/vo "
// факт домена; имя уезжает на шину как тип сообщения
type Event interface {
FactName () string
}
// счёт выставлен
type Issued struct {
Number string
Customer string
Total vo . Money
}
func ( Issued ) FactName () string { return "invoice.issued" }
// счёт оплачен
type Paid struct {
Number string
}
func ( Paid ) FactName () string { return "invoice.paid" }
// счёт получил пеню
type PenaltyCharged struct {
Number string
Amount vo . Money
}
func ( PenaltyCharged ) FactName () string { return "invoice.penalty_charged" }
billing/invoice/domains/invoice/calendar.go package invoice
import " time "
// календарь рабочих дней: часть модели, а не порт к базе
type Calendar interface {
Workdays ( from time . Time , to time . Time ) int
}
billing/invoice/domains/invoice/services/penalty/penalty.go package penalty
import (
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// правило: счёт, ставка и календарь — на выходе сумма
func Amount (
overdue * invoice . Invoice ,
now time . Time ,
perWorkday vo . Money ,
calendar invoice . Calendar ,
) vo . Money {
// срок правило берёт у самого счёта, а не у вызывающего
late := calendar. Workdays (overdue. DueOn (), now)
if late <= 0 {
return vo. NewMoney ( 0 )
}
return vo. NewMoney (perWorkday. Get () * int64 (late))
}
billing/invoice/domains/invoice/repository.go package invoice
import " context "
// порт: как счёт попадает в базу и возвращается оттуда
type Repository interface {
ByNumber ( ctx context . Context , number Number ) ( * Invoice , error )
Save ( ctx context . Context , invoice * Invoice ) error
}
billing/invoice/domains/invoice/publisher.go package invoice
import (
" context "
" billing/invoice/domains/invoice/events "
)
// порт: куда уезжают факты
type Publisher interface {
Publish ( ctx context . Context , facts ... events . Event ) error
}
billing/invoice/domains/invoice/vo/money.go package vo
// сумма в копейках
type Money struct {
minor int64
}
func NewMoney ( minor int64 ) Money {
return Money {minor: minor}
}
func ( m Money ) Add ( other Money ) Money {
return Money {minor: m.minor + other.minor}
}
func ( m Money ) Equal ( other Money ) bool {
return m.minor == other.minor
}
func ( m Money ) Get () int64 {
return m.minor
}
billing/invoice/domains/invoice/rules/overdue.go package rules
import (
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// контракт спецификации: один вопрос про один агрегат
type Specification interface {
IsSatisfiedBy ( inv * invoice . Invoice ) bool
}
// предикат: этот счёт просрочен?
type Overdue struct {
now time . Time
calendar invoice . Calendar
}
func NewOverdue ( now time . Time , calendar invoice . Calendar ) Overdue {
return Overdue {now: now, calendar: calendar}
}
// оплаченный счёт просроченным не бывает
func ( o Overdue ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
if inv. Status () == invoice.StatusPaid {
return false
}
return o.calendar. Workdays (inv. DueOn (), o.now) > 0
}
// второй предикат: счёт больше лимита
type OverBudget struct {
limit vo . Money
}
func NewOverBudget ( limit vo . Money ) OverBudget {
return OverBudget {limit: limit}
}
func ( b OverBudget ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
return inv. Total (). Get () > b.limit. Get ()
}
// сборка: требует все предикаты сразу
type All struct {
specs [] Specification
}
func AllOf ( specs ... Specification ) All {
return All {specs: specs}
}
func ( a All ) IsSatisfiedBy ( inv * invoice . Invoice ) bool {
for _, spec := range a.specs {
if ! spec. IsSatisfiedBy (inv) {
return false
}
}
return true
}
billing/invoice/applications/overdue/charge.go package overdue
import (
" context "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/rules "
" billing/invoice/domains/invoice/services/penalty "
" billing/invoice/domains/invoice/vo "
)
// два порта, календарь и ставка — всё, что нужно сценарию
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
calendar invoice . Calendar
perWorkday vo . Money
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
calendar invoice . Calendar ,
perWorkday vo . Money ,
) * UseCase {
return & UseCase {
uow: uow,
invoices: invoices,
publisher: publisher,
calendar: calendar,
perWorkday: perWorkday,
}
}
// достать, посчитать, применить, сохранить, рассказать
func ( u * UseCase ) Run ( ctx context . Context , number invoice . Number , now time . Time ) error {
return u.uow. Do (ctx, func ( ctx context . Context ) error {
overdue, err := u.invoices. ByNumber (ctx, number)
if err != nil {
return err
}
// сценарий спрашивает у правила, а не верит вызывающему
if ! rules. NewOverdue (now, u.calendar). IsSatisfiedBy (overdue) {
return nil
}
// правило считает сумму по счёту, ставке и календарю
amount := penalty. Amount (overdue, now, u.perWorkday, u.calendar)
// приписать пеню счёту может только сам счёт
if err := overdue. ChargePenalty (amount); err != nil {
return err
}
if err := u.invoices. Save (ctx, overdue); err != nil {
return err
}
return u.publisher. Publish (ctx, overdue. Events () ... )
})
}
billing/invoice/applications/issuing/issue.go package issuing
import (
" context "
" time "
" billing/invoice/domains/invoice "
)
// зависимости — только порты домена
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// порядок тот же, изменилась граница
func ( u * UseCase ) Run (
ctx context . Context ,
number invoice . Number ,
customer invoice . CustomerID ,
lines [] invoice . Line ,
dueOn time . Time ,
) error {
issued, err := invoice. Issue (number, customer, lines, dueOn)
if err != nil {
return err
}
// сохранение и факты — внутри одной операции
return u.uow. Do (ctx, func ( ctx context . Context ) error {
if err := u.invoices. Save (ctx, issued); err != nil {
return err
}
return u.publisher. Publish (ctx, issued. Events () ... )
})
}
billing/invoice/applications/issuing/issue.go package issuing
import (
" context "
" time "
" billing/invoice/domains/invoice "
)
// зависимости — только порты домена
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// один аргумент: команда, и она же проверяет свою форму
func ( u * UseCase ) Handle ( ctx context . Context , cmd IssueInvoice ) error {
if err := cmd. Validate (); err != nil {
return err
}
issued, err := invoice. Issue (cmd.Number, cmd.Customer, cmd.Lines, cmd.DueOn)
if err != nil {
return err
}
return u.uow. Do (ctx, func ( ctx context . Context ) error {
if err := u.invoices. Save (ctx, issued); err != nil {
return err
}
return u.publisher. Publish (ctx, issued. Events () ... )
})
}
billing/invoice/applications/payment/pay.go package payment
import (
" context "
" billing/invoice/domains/invoice "
)
// зависимости — только порты домена
type UseCase struct {
uow invoice . UnitOfWork
invoices invoice . Repository
publisher invoice . Publisher
}
func New (
uow invoice . UnitOfWork ,
invoices invoice . Repository ,
publisher invoice . Publisher ,
) * UseCase {
return & UseCase {uow: uow, invoices: invoices, publisher: publisher}
}
// достать, вызвать метод, сохранить, рассказать
func ( u * UseCase ) Run ( ctx context . Context , number invoice . Number ) error {
return u.uow. Do (ctx, func ( ctx context . Context ) error {
paid, err := u.invoices. ByNumber (ctx, number)
if err != nil {
return err
}
if err := paid. MarkPaid (); err != nil {
return err
}
if err := u.invoices. Save (ctx, paid); err != nil {
return err
}
return u.publisher. Publish (ctx, paid. Events () ... )
})
}
billing/invoice/domains/invoice/load.go package invoice
import (
" time "
" billing/invoice/domains/invoice/vo "
)
// собирает счёт из того, что лежало в базе
func Load (
number Number ,
customer CustomerID ,
lines [] Line ,
total vo . Money ,
dueOn time . Time ,
status Status ,
) * Invoice {
return & Invoice {
number: number,
customer: customer,
lines: lines,
total: total,
dueOn: dueOn,
status: status,
}
}
billing/invoice/domains/invoice/uow.go package invoice
import " context "
// порт: выполнить работу целиком или не выполнять
type UnitOfWork interface {
Do ( ctx context . Context , work func ( ctx context . Context ) error ) error
}
billing/invoice/infrastructure/postgres/invoices.go package postgres
import (
" context "
" database/sql "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// реализация порта хранения на Postgres
type Invoices struct {
db * sql . DB
}
func NewInvoices ( db * sql . DB ) * Invoices {
return & Invoices {db: db}
}
// достаёт счёт и собирает агрегат
func ( r * Invoices ) ByNumber ( ctx context . Context , number invoice . Number ) ( * invoice . Invoice , error ) {
var (
customer string
minor int64
dueOn time . Time
status string
)
err := conn (ctx, r.db).
QueryRowContext (ctx, selectInvoice, string (number)).
Scan ( & customer, & minor, & dueOn, & status)
if err != nil {
return nil , err
}
lines, err := r. lines (ctx, number)
if err != nil {
return nil , err
}
// сборка агрегата — дело домена
return invoice. Load (
number,
invoice. CustomerID (customer),
lines,
vo. NewMoney (minor),
dueOn,
invoice. Status (status),
), nil
}
// пишет счёт и его строки
func ( r * Invoices ) Save ( ctx context . Context , saved * invoice . Invoice ) error {
db := conn (ctx, r.db)
if _, err := db. ExecContext (ctx, upsertInvoice, saved); err != nil {
return err
}
_, err := db. ExecContext (ctx, replaceLines, saved)
return err
}
billing/invoice/infrastructure/postgres/uow.go package postgres
import (
" context "
" database/sql "
)
type txKey struct {}
// реализация единицы работы: транзакция Postgres
type UnitOfWork struct {
db * sql . DB
}
func NewUnitOfWork ( db * sql . DB ) * UnitOfWork {
return & UnitOfWork {db: db}
}
// вся работа внутри одной транзакции
func ( u * UnitOfWork ) Do ( ctx context . Context , work func ( ctx context . Context ) error ) error {
tx, err := u.db. BeginTx (ctx, nil )
if err != nil {
return err
}
if err := work (context. WithValue (ctx, txKey {}, tx)); err != nil {
// ошибка внутри — откат целиком
_ = tx. Rollback ()
return err
}
return tx. Commit ()
}
// транзакция из ctx, если операция уже началась
func conn ( ctx context . Context , db * sql . DB ) interface {
ExecContext ( context . Context , string , ... any ) ( sql . Result , error )
QueryRowContext ( context . Context , string , ... any ) * sql . Row
} {
if tx, ok := ctx. Value ( txKey {}).( * sql . Tx ); ok {
return tx
}
return db
}
billing/invoice/infrastructure/postgres/outbox.go package postgres
import (
" context "
" database/sql "
" encoding/json "
" billing/invoice/domains/invoice/events "
)
// реализация публикации: факты строками в базе
type Outbox struct {
db * sql . DB
}
func NewOutbox ( db * sql . DB ) * Outbox {
return & Outbox {db: db}
}
// пишет факты в ту же транзакцию
func ( o * Outbox ) Publish ( ctx context . Context , facts ... events . Event ) error {
db := conn (ctx, o.db)
for _, event := range facts {
body, err := json. Marshal (event)
if err != nil {
return err
}
// имя факта и тело сообщения
if _, err := db. ExecContext (ctx, insertOutbox, event. FactName (), body); err != nil {
return err
}
}
return nil
}
billing/invoice/applications/issuing/transport/http/dto/issue.go package dto
import (
" errors "
" time "
" billing/invoice/domains/invoice "
" billing/invoice/domains/invoice/vo "
)
// тело запроса: то, в чём данные пересекают границу
type IssueRequest struct {
Number string `json:"number"`
Customer string `json:"customer"`
DueOn time . Time `json:"due_on"`
Lines [] Line `json:"lines"`
}
// строка счёта так, как её присылают
type Line struct {
AmountMinor int64 `json:"amount_minor"`
}
var ErrEmptyNumber = errors. New ( "dto: number is empty" )
// проверяется форма запроса, а не правила предметной области
func ( r IssueRequest ) Validate () error {
if r.Number == "" {
return ErrEmptyNumber
}
return nil
}
// что из запроса нужно сценарию, уже в типах домена
type Issue struct {
Number invoice . Number
Customer invoice . CustomerID
Lines [] invoice . Line
DueOn time . Time
}
// переводит тело запроса в типы домена
func ( r IssueRequest ) ToDomain () Issue {
lines := make ([] invoice . Line , 0 , len (r.Lines))
for _, item := range r.Lines {
lines = append (lines, invoice. NewLine (vo. NewMoney (item.AmountMinor)))
}
return Issue {
Number: invoice. Number (r.Number),
Customer: invoice. CustomerID (r.Customer),
Lines: lines,
DueOn: r.DueOn,
}
}
billing/invoice/applications/issuing/transport/http/handler.go package http
import (
" encoding/json "
" net/http "
" billing/invoice/applications/issuing "
" billing/invoice/applications/issuing/transport/http/dto "
)
// ручка владеет сценарием и больше ничем
type Handler struct {
issue * issuing . UseCase
}
func NewHandler ( issue * issuing . UseCase ) * Handler {
return & Handler {issue: issue}
}
// POST /invoices: разобрать, перевести, позвать
func ( h * Handler ) Issue ( w http . ResponseWriter , r * http . Request ) {
var body dto . IssueRequest
// тело не читается — это ошибка клиента
if err := json. NewDecoder (r.Body). Decode ( & body); err != nil {
http. Error (w, "malformed json" , http.StatusBadRequest)
return
}
if err := body. Validate (); err != nil {
http. Error (w, err. Error (), http.StatusUnprocessableEntity)
return
}
// перевод в типы домена — дело пакета dto
args := body. ToDomain ()
// сценарий получает доменные сущности, а не запрос
err := h.issue. Run (r. Context (), args.Number, args.Customer, args.Lines, args.DueOn)
if err != nil {
// ошибку домена переводит в код ответа отдельная функция
fail (w, err)
return
}
// 201 и пустое тело: номер клиент прислал сам
w. WriteHeader (http.StatusCreated)
}
billing/invoice/applications/issuing/transport/http/handler.go package http
import (
" encoding/json "
" net/http "
" billing/invoice/applications/issuing "
" billing/invoice/applications/issuing/transport/http/dto "
)
// ручка владеет сценарием и больше ничем
type Handler struct {
issue * issuing . UseCase
}
func NewHandler ( issue * issuing . UseCase ) * Handler {
return & Handler {issue: issue}
}
// POST /invoices: разобрать, перевести, позвать
func ( h * Handler ) Issue ( w http . ResponseWriter , r * http . Request ) {
var body dto . IssueRequest
// тело не читается — это ошибка клиента
if err := json. NewDecoder (r.Body). Decode ( & body); err != nil {
http. Error (w, "malformed json" , http.StatusBadRequest)
return
}
if err := body. Validate (); err != nil {
http. Error (w, err. Error (), http.StatusUnprocessableEntity)
return
}
// перевод в типы домена — дело пакета dto
args := body. ToDomain ()
// транспорт собирает команду — дальше едет она, а не россыпь аргументов
cmd := issuing . IssueInvoice {
Number: args.Number,
Customer: args.Customer,
Lines: args.Lines,
DueOn: args.DueOn,
}
// сценарий получает доменные сущности, а не запрос
err := h.issue. Handle (r. Context (), cmd)
if err != nil {
// ошибку домена переводит в код ответа отдельная функция
fail (w, err)
return
}
// 201 и пустое тело: номер клиент прислал сам
w. WriteHeader (http.StatusCreated)
}
billing/invoice/applications/issuing/transport/http/errors.go package http
import (
" errors "
" log/slog "
" net/http "
" billing/invoice/domains/invoice "
)
// переводит ошибку домена в код ответа
func fail ( w http . ResponseWriter , err error ) {
switch {
// правило домена нарушено — это ошибка запроса, а не сервера
case errors. Is (err, invoice.ErrNoLines):
http. Error (w, "invoice has no lines" , http.StatusUnprocessableEntity)
case errors. Is (err, invoice.ErrAlreadyPaid):
http. Error (w, "invoice is already paid" , http.StatusConflict)
default :
// причина остаётся в логе, наружу — код
slog. Error ( "issue invoice" , "error" , err)
http. Error (w, "internal error" , http.StatusInternalServerError)
}
}
billing/invoice/applications/issuing/transport/http/routes.go package http
import " net/http "
// адрес ручки объявляет сам модуль
func ( h * Handler ) Register ( mux * http . ServeMux ) {
mux. HandleFunc ( "POST /invoices" , h.Issue)
}
billing/invoice/cmd/api/main.go package main
import (
" log "
" net/http "
" os "
_ " github.com/lib/pq "
)
// сборка уехала в сгенерированную функцию
func main () {
handler, err := initHandler (os. Getenv ( "DATABASE_URL" ))
if err != nil {
log. Fatal (err)
}
mux := http. NewServeMux ()
handler. Register (mux)
log. Fatal (http. ListenAndServe ( ":8080" , mux))
}
billing/invoice/cmd/api/wire.go //go:build wireinject
package main
import (
" database/sql "
" github.com/google/wire "
" billing/invoice/domains/invoice "
" billing/invoice/infrastructure/postgres "
" billing/invoice/applications/issuing "
issuinghttp " billing/invoice/applications/issuing/transport/http "
)
// набор адаптеров: один на все приложения сервиса
var adapters = wire. NewSet (
postgres.NewUnitOfWork,
postgres.NewInvoices,
postgres.NewOutbox,
// какая реализация стоит за портом
wire. Bind ( new ( invoice . UnitOfWork ), new ( * postgres . UnitOfWork )),
wire. Bind ( new ( invoice . Repository ), new ( * postgres . Invoices )),
wire. Bind ( new ( invoice . Publisher ), new ( * postgres . Outbox )),
)
// что надо собрать — по типу результата
func initHandler ( dsn string ) ( * issuinghttp . Handler , error ) {
wire. Build (openDB, adapters, issuing.New, issuinghttp.NewHandler)
// тело-заглушка: настоящее напишет генератор
return nil , nil
}
func openDB ( dsn string ) ( * sql . DB , error ) {
return sql. Open ( "postgres" , dsn)
}
billing/invoice/applications/issuing/command.go package issuing
import (
" errors "
" time "
" billing/invoice/domains/invoice "
)
// команда: что сделать и с какими данными
type IssueInvoice struct {
Number invoice . Number
Customer invoice . CustomerID
Lines [] invoice . Line
DueOn time . Time
}
var ErrNoNumber = errors. New ( "issuing: invoice number is empty" )
// проверяется форма, а не правила предметной области
func ( c IssueInvoice ) Validate () error {
if c.Number == "" {
return ErrNoNumber
}
return nil
}
billing/invoice/applications/views/card.go package views
import (
" context "
" billing/invoice/domains/invoice "
)
// карточка счёта для экрана, а не агрегат
type Card struct {
Number string
Total int64
Status string
Overdue bool
}
// запрос: что просят прочитать, названное типом
type CardQuery struct {
Number invoice . Number
}
// порт чтения: отдаёт готовую карточку
type Cards interface {
Card ( ctx context . Context , query CardQuery ) ( Card , error )
}
billing/invoice/infrastructure/postgres/cards.go package postgres
import (
" context "
" database/sql "
" billing/invoice/applications/views "
" billing/invoice/domains/invoice "
)
// запрос отдаёт ровно то, что показывают на экране
const selectCard = `
SELECT number,
total_minor,
status,
due_on < now() AND status <> 'paid' AS overdue
FROM invoices
WHERE number = $1`
// реализация порта чтения: один запрос, без агрегата
type Cards struct {
db * sql . DB
}
func NewCards ( db * sql . DB ) * Cards {
return & Cards {db: db}
}
// читает карточку счёта по номеру
func ( c * Cards ) Card ( ctx context . Context , query views . CardQuery ) ( views . Card , error ) {
var card views . Card
// поля запроса ложатся прямо в проекцию
err := c.db. QueryRowContext (ctx, selectCard, string (query.Number)).
Scan ( & card.Number, & card.Total, & card.Status, & card.Overdue)
if err != nil {
return views . Card {}, err
}
return card, nil
}