feat(payments): settle the direct rail through YooKassa
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
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
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.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user