Files
scrabble-game/backend/internal/yookassa/receipt.go
T
Ilia Denisov 92ba527575
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
feat(payments): settle the direct rail through YooKassa
Replace Robokassa with YooKassa as the RUB direct-rail provider. The wallet
model is untouched: one `direct` segment, the same spend wall, the same
per-channel merchant shops (D42) and `shop` on the order (D44).

The two providers are not shaped alike, and that drives the change:

- Opening a purchase is now an outbound API call (`POST /v3/payments`,
  single-stage capture, redirect confirmation). The order id is both the
  `Idempotence-Key` and `metadata.order_id`, so a retried create cannot mint a
  second payment and a notification always resolves to its order.
- YooKassa does NOT sign notifications, so the body is never evidence: it only
  names a payment, which is re-read with `GET /v3/payments/{id}`, and only that
  answer is acted on. Two guards ride on it — the payment's metadata must name
  the order, and its `test` flag must match the shop's, so a test-shop payment
  can never credit real chips. The sender address is checked against YooKassa's
  published ranges first, which stops a forger turning each fabricated
  notification into an outbound call of ours.
- A notification lost for good would leave the money taken and the chips unowed,
  silently. The existing pending-order reaper now asks the provider about each
  order that reached its expiry age carrying a payment id, and credits the ones
  really paid — one request per order over its whole life, not polling.
- `payment.canceled` records a `failed` event, so a declined payment is finally
  surfaced to the customer as PAYMENTS.md §9 already specified.
- The admin refund moves the money through `POST /v3/refunds` before recording
  anything; a failed call records nothing, so the ledger cannot claim a refund
  that did not happen, and the recorded id is the provider's own.
- YooKassa has no cabinet-side generic receipt: «Чеки от ЮKassa» registers one
  only if the request carries it, so every payment and refund now sends an
  itemized `receipt` to the D36 confirmed email. The VAT rate code is a deploy
  variable; the settlement subject and method are constants.

Robokassa is retired, not deleted: the direct rail falls back to it when no
YooKassa shop is configured and no deployment sets its credentials, so reviving
it is a credentials change rather than a code change. Its variables are removed
from compose, .env.example, write-prod-env.sh and the three workflows, and
recorded in backend/internal/robokassa/README.md together with the cabinet
configuration and the revival steps. Ledger rows keep `provider = 'robokassa'`;
that literal is load-bearing for the idempotency index.

No migration and no wire change: `orders.provider_payment_id` already existed,
and the client is rail-agnostic.

Decisions D47-D51 (revising D41) and stage E12 are baked into the docs.
2026-07-28 08:51:31 +02:00

83 lines
3.6 KiB
Go

package yookassa
// Fiscal receipt attributes for «Чеки от ЮKassa» (54-ФЗ). YooKassa registers the receipt itself —
// no cash register to rent, no OFD contract — but only if the payment and refund requests carry a
// receipt object, so unlike the previous rail's cabinet-side receipts these fields are load-bearing
// code. Reference:
// https://yookassa.ru/developers/payment-acceptance/receipts/54fz/yoomoney/parameters-values
const (
// PaymentSubjectService is the settlement subject (54-ФЗ tag 1212) — what the money is taken
// for. Chips are sold as a service ("Услуга").
PaymentSubjectService = "service"
// PaymentModeFullPayment is the settlement method (54-ФЗ tag 1214) — the payer pays and receives
// the goods at once ("Полный расчет"). «Чеки от ЮKassa» accept only this and full_prepayment;
// partial prepayment, advance and credit are not supported.
PaymentModeFullPayment = "full_payment"
// VatCodeNone is the VAT rate code (54-ФЗ tag 1199) for «Без НДС» — the rate a sole proprietor on
// УСН or ПСН charges. It is the default; the deployed value is configurable because the rate is
// the one receipt attribute that genuinely changes (Russia raised the main rate on 1 Jan 2026,
// adding codes 11 and 12).
VatCodeNone = 1
// VatCodeMax is the highest VAT rate code the API accepts; codes run 1..12.
VatCodeMax = 12
)
// Customer is the payer's contact data for delivering the receipt. «Чеки от ЮKassa» deliver only by
// email (no SMS), so Email is required — the direct rail's confirmed email anchor supplies it.
type Customer struct {
Email string `json:"email,omitempty"`
}
// ReceiptItem is one line of a fiscal receipt: what was sold, how much of it, for how much, and the
// three fiscal attributes that classify it.
type ReceiptItem struct {
Description string `json:"description"`
Quantity float64 `json:"quantity"`
Amount Amount `json:"amount"`
VatCode int `json:"vat_code"`
PaymentSubject string `json:"payment_subject"`
PaymentMode string `json:"payment_mode"`
}
// Receipt is the fiscal receipt data sent alongside a payment or a refund. tax_system_code is
// deliberately absent: YooKassa ignores it for «Чеки от ЮKassa».
type Receipt struct {
Customer Customer `json:"customer"`
Items []ReceiptItem `json:"items"`
}
// maxDescriptionRunes is the API's limit for both a payment description and a receipt line
// description (54-ФЗ tag 1030). A longer title is truncated rather than rejected, so an over-long
// product name cannot block a purchase.
const maxDescriptionRunes = 128
// SingleItemReceipt builds a one-line receipt for selling title at amount to email. Chip packs are
// always a single line bought once, so quantity is 1 and the line amount is the whole payment.
func SingleItemReceipt(email, title string, amount Amount, vatCode int) *Receipt {
return &Receipt{
Customer: Customer{Email: email},
Items: []ReceiptItem{{
Description: TruncateDescription(title),
Quantity: 1,
Amount: amount,
VatCode: vatCode,
PaymentSubject: PaymentSubjectService,
PaymentMode: PaymentModeFullPayment,
}},
}
}
// TruncateDescription shortens s to the API's description limit, counting runes rather than bytes so
// a Cyrillic title is not cut mid-character.
func TruncateDescription(s string) string {
r := []rune(s)
if len(r) <= maxDescriptionRunes {
return s
}
return string(r[:maxDescriptionRunes])
}
// ValidVatCode reports whether code is within the range the API accepts (1..12).
func ValidVatCode(code int) bool { return code >= 1 && code <= VatCodeMax }