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
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.
83 lines
3.6 KiB
Go
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 }
|