backend old version request

This commit is contained in:
2026-04-30 15:00:24 +04:00
parent d9b0c221f1
commit 678ab3773b
8 changed files with 367 additions and 17 deletions

View File

@@ -39,6 +39,33 @@ Response:
---
## 1.1. `GET /fastcheck` — добавить `amount` в ответ
Фронт автоматически делает `GET /fastcheck` после ввода полного номера
(`xxxx-xxxx-xxxx`), чтобы показать получателю сумму до ввода кода. Сейчас в
`api.txt` ответ содержит только `fastcheck`, `expiration`, `Status` — суммы нет.
Добавить:
```diff
GET /fastcheck
Body: { "fastcheck": "1234-5678-0001" }
Response:
{
"fastcheck": "1234-5678-0001",
"expiration": "2026-07-07T09:08:18Z",
+ "amount": 158000,
+ "currency": "RUB",
+ "note": "За кофе",
"Status": true
}
```
Также: GET с телом — нестандарт, многие HTTP-клиенты его выкидывают. **Принимать
`?fastcheck=...` как query-параметр** (фронт шлёт оба варианта одновременно).
---
## 2. Зафиксировать единицу `amount`
В `api.txt` пример `"amount": 158000` неоднозначен. Зафиксировать:
@@ -144,6 +171,8 @@ Body:
- [ ] DNS + HTTPS + CORS (блокер)
- [ ] `orderId`, `note`, `returnUrl` в `POST /fastcheck` (create)
- [ ] `note` возвращается в `GET /fastcheck`
- [ ] `amount` (+ currency) возвращается в `GET /fastcheck`
- [ ] `GET /fastcheck` принимает `?fastcheck=` как query-param
- [ ] Зафиксировать `amount` в основной единице (рубли)
- [ ] Webhook на `fastcheck.paid` с HMAC-подписью
- [ ] Гранулярные ошибки accept

View File

@@ -3,8 +3,14 @@
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage)
loadComponent: () => {
// Branch: ?id=<orderId> means legacy SBP merchant flow.
const hasLegacyId = typeof window !== 'undefined'
&& new URLSearchParams(window.location.search).has('id');
return hasLegacyId
? import('./pages/legacy-pay-page/legacy-pay-page').then((m) => m.LegacyPayPage)
: import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage);
}
},
{
path: 'new',

View File

@@ -25,10 +25,11 @@
type="text"
class="input"
[ngModel]="fastcheckNumber()"
(ngModelChange)="fastcheckNumber.set($event)"
(ngModelChange)="onNumberChange($event)"
placeholder="1234-5678-0001"
inputmode="numeric"
autocomplete="off"
maxlength="14"
/>
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый fastCHECK">Новый</a>
</div>
@@ -49,8 +50,12 @@
step="1"
inputmode="numeric"
placeholder="0"
[readonly]="amountLoading() || fastcheckAmount() !== null"
/>
</div>
@if (amountLoading()) {
<span class="field__hint">Проверяем…</span>
}
</div>
<!-- Code -->
@@ -61,10 +66,10 @@
type="text"
class="input"
[ngModel]="fastcheckCode()"
(ngModelChange)="fastcheckCode.set($event)"
placeholder="0000"
(ngModelChange)="onCodeChange($event)"
placeholder="00000"
inputmode="numeric"
maxlength="8"
maxlength="5"
autocomplete="one-time-code"
/>
@if (error()) {
@@ -72,7 +77,7 @@
}
</div>
<button class="pay-btn" type="button" (click)="pay()">
<button class="pay-btn" type="button" (click)="pay()" [disabled]="!canPay()">
<span class="pay-btn__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">

View File

@@ -13,6 +13,13 @@ interface WebSessionResponse {
Status: boolean;
}
interface CheckFastcheckResponse {
fastcheck: string;
amount?: number;
expiration: string;
Status: boolean;
}
@Component({
selector: 'app-fastcheck-page',
imports: [FormsModule, RouterLink],
@@ -31,6 +38,7 @@ export class FastcheckPage {
fastcheckAmount = signal<number | null>(null);
fastcheckCode = signal<string>('');
error = signal<string>('');
amountLoading = signal<boolean>(false);
popupOpen = signal<boolean>(false);
popupLoading = signal<boolean>(false);
@@ -38,6 +46,13 @@ export class FastcheckPage {
webSessionId = signal<string>('');
paid = signal<boolean>(false);
private pollHandle: ReturnType<typeof setInterval> | null = null;
private lastLookedUpNumber = '';
canPay = computed(() => {
const digits = this.fastcheckNumber().replace(/\D/g, '');
const codeDigits = this.fastcheckCode().replace(/\D/g, '');
return digits.length === 12 && codeDigits.length === 5 && !this.amountLoading();
});
telegramLink = computed(() => {
const sid = this.webSessionId();
@@ -62,12 +77,7 @@ export class FastcheckPage {
}
pay(): void {
if (!this.fastcheckNumber().trim()) {
this.error.set('Введите номер');
return;
}
if (!this.fastcheckCode().trim()) {
this.error.set('Введите код');
if (!this.canPay()) {
return;
}
this.error.set('');
@@ -168,4 +178,64 @@ export class FastcheckPage {
onAmountChange(value: number | null): void {
this.fastcheckAmount.set(value);
}
/** Mask fastcheck number as XXXX-XXXX-XXXX, allow only digits. */
onNumberChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 12);
const groups: string[] = [];
for (let i = 0; i < digits.length; i += 4) {
groups.push(digits.slice(i, i + 4));
}
const masked = groups.join('-');
this.fastcheckNumber.set(masked);
this.error.set('');
// If number became incomplete, drop the previously fetched amount so the
// user doesn't see a stale value tied to a different (now-edited) number.
if (digits.length < 12 && this.lastLookedUpNumber) {
this.fastcheckAmount.set(null);
this.lastLookedUpNumber = '';
}
// Auto-lookup when 12 digits are entered (and we haven't already looked it up).
if (digits.length === 12 && masked !== this.lastLookedUpNumber) {
this.lookupFastcheck(masked);
}
}
/** Allow only digits, max 5, in the code field. */
onCodeChange(raw: string): void {
const digits = (raw ?? '').replace(/\D/g, '').slice(0, 5);
this.fastcheckCode.set(digits);
this.error.set('');
}
private lookupFastcheck(number: string): void {
this.lastLookedUpNumber = number;
this.amountLoading.set(true);
this.fastcheckAmount.set(null);
// GET /fastcheck — body in GET is non-standard; many HTTP libs strip it.
// The backend should accept ?fastcheck= as a query param too. We send both.
this.http
.request<CheckFastcheckResponse>('GET', `${FASTCHECK_API}/fastcheck`, {
body: { fastcheck: number },
params: { fastcheck: number }
})
.subscribe({
next: (res) => {
this.amountLoading.set(false);
if (res?.Status && typeof res.amount === 'number') {
this.fastcheckAmount.set(res.amount);
} else if (res?.Status === false) {
this.error.set('Платёж не найден или просрочен.');
}
},
error: () => {
this.amountLoading.set(false);
this.error.set('Не удалось проверить номер. Попробуйте ещё раз.');
this.lastLookedUpNumber = '';
}
});
}
}

View File

@@ -0,0 +1,78 @@
<div class="page">
<div class="card">
<div class="card__header">
<div class="sbp-logo">
<img src="https://sbp.nspk.ru/storage/settings/common/logo/0645d335-8b62-43a1-9a33-0d4c9d1dc0e0.svg"
alt="СБП" />
</div>
<h1 class="card__title">Оплата через СБП</h1>
<p class="card__subtitle">Система быстрых платежей</p>
</div>
<div class="card__body">
<div class="field">
<label class="field__label" for="amount">Сумма платежа</label>
<div class="input-wrap" [class.input-wrap--error]="error()">
<span class="input-wrap__prefix"></span>
<input
id="amount"
type="number"
class="input-wrap__input"
[ngModel]="amount()"
(ngModelChange)="onAmountChange($event)"
min="1"
step="1"
inputmode="numeric"
placeholder="0"
autofocus
/>
</div>
@if (error()) {
<span class="field__error">{{ error() }}</span>
}
</div>
<div class="currency-badge">
<span class="currency-badge__flag">🇷🇺</span>
<span class="currency-badge__code">RUB</span>
<span class="currency-badge__name">Российский рубль</span>
</div>
<div class="field">
<label class="field__label" for="note">Примечание</label>
<textarea
id="note"
class="note-input"
[ngModel]="note()"
(ngModelChange)="onNoteChange($event)"
placeholder="Причина платежа..."
rows="3"
maxlength="500"
></textarea>
</div>
<button class="pay-btn" type="button" (click)="pay()" [disabled]="loading()">
<span class="pay-btn__icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
</span>
{{ loading() ? 'Подождите...' : 'Перейти к оплате' }}
</button>
</div>
<div class="card__footer">
<span class="secure-badge">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
Защищённое соединение
</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,60 @@
@use './../../../shared' as *;
.sbp-logo {
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
border-radius: 16px;
padding: 12px 20px;
border: 1px solid rgba(255, 255, 255, 0.25);
margin-bottom: 14px;
img {
height: 40px;
display: block;
@media (max-width: 480px) {
height: 34px;
}
}
}
.currency-badge {
display: flex;
align-items: center;
gap: 10px;
background: #f1f5f9;
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 18px;
&__flag { font-size: 22px; line-height: 1; }
&__code { font-size: 15px; font-weight: 700; color: #0f172a; }
&__name { font-size: 13px; color: #64748b; margin-left: auto; }
}
.note-input {
width: 100%;
border: 2px solid #e2e8f0;
border-radius: 14px;
background: #f8fafc;
padding: 14px 16px;
font-size: 15px;
font-weight: 500;
color: #0f172a;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
line-height: 1.5;
&::placeholder { color: #cbd5e1; font-weight: 400; }
&:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
background: #fff;
}
}

View File

@@ -0,0 +1,91 @@
import { Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { HttpClient } from '@angular/common/http';
interface LegacyPayResponse {
payload?: string;
}
/**
* Legacy SBP merchant payment flow.
* Activated when the root URL has `?id=<orderId>`.
* Mirrors public/payment.html behaviour:
* POST https://qr.vitanova.network:567/qr
* { payment, amount, currency, id, note } -> { payload: '<sbp-deep-link>' }
* then window.location.href = payload.
*/
@Component({
selector: 'app-legacy-pay-page',
imports: [FormsModule],
templateUrl: './legacy-pay-page.html',
styleUrl: './legacy-pay-page.scss'
})
export class LegacyPayPage {
private http = inject(HttpClient);
private route = inject(ActivatedRoute);
private readonly LEGACY_API = 'https://qr.vitanova.network:567/qr';
amount = signal<number | null>(null);
note = signal<string>('');
error = signal<string>('');
loading = signal<boolean>(false);
paymentId = signal<string>('');
canPay = computed(() => {
const a = this.amount();
return !!this.paymentId() && a !== null && a > 0 && !this.loading();
});
constructor() {
const id = this.route.snapshot.queryParamMap.get('id') ?? '';
this.paymentId.set(id);
}
onAmountChange(value: number | null): void {
this.amount.set(value);
if (this.error()) this.error.set('');
}
onNoteChange(value: string): void {
this.note.set(value);
}
pay(): void {
if (!this.canPay()) {
if (!this.paymentId()) {
this.error.set('Не указан идентификатор платежа (параметр id)');
} else {
this.error.set('Введите корректную сумму');
}
return;
}
this.error.set('');
this.loading.set(true);
const body = {
payment: 'sbp',
amount: this.amount(),
currency: 'rub',
id: this.paymentId(),
note: this.note().trim()
};
this.http.post<LegacyPayResponse>(this.LEGACY_API, body).subscribe({
next: (res) => {
this.loading.set(false);
if (res?.payload) {
window.location.href = res.payload;
} else {
this.error.set('Сервер не вернул ссылку для оплаты.');
}
},
error: () => {
this.loading.set(false);
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
}
});
}
}

View File

@@ -114,6 +114,14 @@
color: #ef4444;
font-weight: 500;
}
&__hint {
display: block;
margin-top: 6px;
font-size: 13px;
color: #64748b;
font-weight: 500;
}
}
.input-wrap {
@@ -191,6 +199,7 @@
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
font-family: inherit;
appearance: none;
-webkit-appearance: none;
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
@@ -212,17 +221,18 @@
}
// ─── Brand wordmark: "fastCHECK" inline ─────────────────────────────────────
// "fast" is rendered half the size of "CHECK".
// "fast" sits a bit smaller and lighter than "CHECK".
.brand {
display: inline-flex;
align-items: baseline;
font-weight: 800;
font-weight: inherit;
letter-spacing: -0.02em;
white-space: nowrap;
font-size: calc(1em + 3px);
&__fast {
font-size: 0.5em;
font-weight: 700;
font-size: 0.72em;
font-weight: 400;
text-transform: lowercase;
margin-right: 0.05em;
opacity: 0.85;
@@ -230,6 +240,7 @@
&__check {
font-size: 1em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
}