Compare commits
2 Commits
d6e1b30554
...
678ab3773b
| Author | SHA1 | Date | |
|---|---|---|---|
| 678ab3773b | |||
| d9b0c221f1 |
3
.gitignore
vendored
@@ -4,6 +4,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
/dist
|
||||||
|
changes.txt
|
||||||
|
api.txt
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
179
BACKEND_CHANGES.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Fastcheck Backend — изменения сверх api.txt
|
||||||
|
|
||||||
|
Документ с **дельтой** — что нужно добавить/изменить в спеке `public/api.txt`,
|
||||||
|
чтобы фронт полноценно заработал. Базовая спека остаётся в силе, здесь только
|
||||||
|
правки.
|
||||||
|
|
||||||
|
> Полный референс с примерами — см. `BACKEND.md` в этом же репозитории.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `POST /fastcheck` (создание) — расширить тело
|
||||||
|
|
||||||
|
Добавить поля `orderId`, `note`, `returnUrl` (все опциональные):
|
||||||
|
|
||||||
|
```diff
|
||||||
|
POST /fastcheck
|
||||||
|
HEADER: Authorization: {"sessionID": "..."}
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"amount": 158000,
|
||||||
|
"currency": "RUB",
|
||||||
|
+ "orderId": "merchant-order-uuid", // id заказа на стороне мерчанта
|
||||||
|
+ "note": "Оплата заказа №123", // комментарий, видит получатель
|
||||||
|
+ "returnUrl": "https://shop.example.com/thanks" // куда вернуть юзера после оплаты
|
||||||
|
}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"fastcheck": "1234-5678-0001",
|
||||||
|
"expiration": "2026-07-07T09:08:18Z",
|
||||||
|
"code": "5864",
|
||||||
|
"Status": true,
|
||||||
|
+ "orderId": "merchant-order-uuid" // эхо обратно
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`note` также возвращать в `GET /fastcheck`, чтобы получатель видел причину
|
||||||
|
платежа перед приёмом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` неоднозначен. Зафиксировать:
|
||||||
|
|
||||||
|
> `amount` — **целое число в основной единице валюты** (для RUB — рубли,
|
||||||
|
> не копейки). Минимум 1.
|
||||||
|
|
||||||
|
Если бэкенд считает в копейках — сообщить, фронт изменит формат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Merchant webhook (новое)
|
||||||
|
|
||||||
|
После успешного `POST /fastcheck` (accept), сервер шлёт `POST` на
|
||||||
|
`webhookUrl`, привязанный к `orderId`/мерчанту:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST <merchant_webhook_url>
|
||||||
|
Headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Fastcheck-Signature: <hmac-sha256 от тела с секретом мерчанта>
|
||||||
|
|
||||||
|
Body:
|
||||||
|
{
|
||||||
|
"event": "fastcheck.paid",
|
||||||
|
"fastcheck": "1234-5678-0001",
|
||||||
|
"orderId": "merchant-order-uuid",
|
||||||
|
"amount": 158000,
|
||||||
|
"currency": "RUB",
|
||||||
|
"paidAt": "2026-04-30T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Мерчант проверяет подпись и помечает заказ оплаченным. Ретраи (минимум
|
||||||
|
3 попытки с экспоненциальной задержкой) при не-2xx ответах.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Развести create и accept на разные пути (рекомендация)
|
||||||
|
|
||||||
|
Сейчас `POST /fastcheck` делает оба действия — отличается только формой тела
|
||||||
|
(`amount` vs `code`). Это хрупко.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- POST /fastcheck (создание)
|
||||||
|
- POST /fastcheck (приём)
|
||||||
|
+ POST /fastcheck (создание)
|
||||||
|
+ POST /fastcheck/accept (приём)
|
||||||
|
```
|
||||||
|
|
||||||
|
Фронт правится в одну строку. Если оставляете один путь — оставляем как есть.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Гранулярные ошибки `POST /fastcheck` (accept)
|
||||||
|
|
||||||
|
В `api.txt` любая ошибка = `404 { "message": "not authorized" }`. Юзер не
|
||||||
|
понимает, что пошло не так. Различать:
|
||||||
|
|
||||||
|
```
|
||||||
|
401 { "message": "not authorized" } — нет/невалидная сессия
|
||||||
|
404 { "message": "fastcheck not found" } — нет такого номера
|
||||||
|
403 { "message": "wrong code" } — код неверный
|
||||||
|
410 { "message": "already used" } — чек уже погашен
|
||||||
|
410 { "message": "expired" } — просрочен
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CORS + HTTPS + DNS (блокер)
|
||||||
|
|
||||||
|
Сейчас `https://api.fastcheck.store` даёт `ERR_NAME_NOT_RESOLVED` —
|
||||||
|
домен не резолвится. Без этого тестировать нечего.
|
||||||
|
|
||||||
|
Минимально:
|
||||||
|
- Поднять DNS A-запись на `api.fastcheck.store`.
|
||||||
|
- Валидный TLS-сертификат (Let's Encrypt подойдёт).
|
||||||
|
- На все эндпоинты + `OPTIONS` отвечать заголовками:
|
||||||
|
```
|
||||||
|
Access-Control-Allow-Origin: https://<домен-фронта>
|
||||||
|
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
|
||||||
|
Access-Control-Allow-Headers: Content-Type, Authorization
|
||||||
|
Access-Control-Max-Age: 86400
|
||||||
|
```
|
||||||
|
`OPTIONS` → `204 No Content` без тела.
|
||||||
|
|
||||||
|
Подробности — `BACKEND.md` §1.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что **не меняется**
|
||||||
|
|
||||||
|
- `GET /ping`
|
||||||
|
- `GET /websession`, `GET /websession/:id`, `DELETE /websession/:id`
|
||||||
|
- `GET /fastcheck`
|
||||||
|
- Формат заголовка `Authorization: {"sessionID":"..."}`
|
||||||
|
- Telegram-логин через бот `@DexarSupport_bot` с `?start=<sessionId>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист для бэкенда
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [ ] (опц.) Развести create / accept на разные пути
|
||||||
52
dist/3rdpartylicenses.txt
vendored
@@ -1,4 +1,30 @@
|
|||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Package: @angular/forms
|
||||||
|
License: "MIT"
|
||||||
|
|
||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
Package: @angular/core
|
Package: @angular/core
|
||||||
License: "MIT"
|
License: "MIT"
|
||||||
@@ -327,29 +353,3 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
Package: @angular/forms
|
|
||||||
License: "MIT"
|
|
||||||
|
|
||||||
The MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2010-2026 Google LLC. https://angular.dev/license
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
|
||||||
|
|||||||
BIN
dist/favicon.ico
vendored
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |
4
dist/favicon.svg
vendored
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" rx="7" fill="#2563eb"/>
|
|
||||||
<text x="16" y="23" font-family="Arial, sans-serif" font-size="20" font-weight="bold" fill="white" text-anchor="middle">₽</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 256 B |
2
dist/index.html
vendored
@@ -13,5 +13,5 @@
|
|||||||
<style>*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background:#1e40af}</style><link rel="stylesheet" href="styles-4STSJS4C.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-4STSJS4C.css"></noscript></head>
|
<style>*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background:#1e40af}</style><link rel="stylesheet" href="styles-4STSJS4C.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-4STSJS4C.css"></noscript></head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
<script src="main-S3GKETUH.js" type="module"></script></body>
|
<link rel="modulepreload" href="chunk-FBABAKVO.js"><script src="main-XKBOOIAP.js" type="module"></script></body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
5
dist/main-S3GKETUH.js
vendored
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/logo_big.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/logo_small.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
@@ -3,8 +3,14 @@
|
|||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () => {
|
||||||
import('./pages/fastcheck-page/fastcheck-page').then((m) => m.FastcheckPage)
|
// 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',
|
path: 'new',
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
<path d="M15 18l-6-6 6-6" />
|
<path d="M15 18l-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="card__title">Новый Фастчек</h1>
|
<h1 class="card__title">
|
||||||
|
Новый
|
||||||
|
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||||
|
</h1>
|
||||||
<p class="card__subtitle">Укажите сумму для пополнения</p>
|
<p class="card__subtitle">Укажите сумму для пополнения</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,7 +114,12 @@
|
|||||||
<path d="M12 5v14M5 12h14" />
|
<path d="M12 5v14M5 12h14" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{{ loading() ? 'Создание…' : 'Создать Фастчек' }}
|
@if (loading()) {
|
||||||
|
Создание…
|
||||||
|
} @else {
|
||||||
|
Создать
|
||||||
|
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ export class CreatePage {
|
|||||||
});
|
});
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
} else {
|
} else {
|
||||||
this.error.set('Не удалось создать Фастчек.');
|
this.error.set('Не удалось создать платёж.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.error.set('Ошибка при создании Фастчека. Попробуйте ещё раз.');
|
this.error.set('Ошибка при создании платежа. Попробуйте ещё раз.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,36 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<h1 class="card__title">Оплата Фастчеком</h1>
|
<img class="card__brand" src="/logo_big.png"
|
||||||
<p class="card__subtitle">Введите данные Фастчека или создайте новый</p>
|
alt="fastCHECK" width="220" height="60" />
|
||||||
|
<p class="card__subtitle">
|
||||||
|
Введите данные
|
||||||
|
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||||
|
или создайте новый
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
|
|
||||||
<!-- Fastcheck number + new -->
|
<!-- Fastcheck number + new -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field__label" for="fcNumber">Номер Фастчека</label>
|
<label class="field__label" for="fcNumber">
|
||||||
|
Номер
|
||||||
|
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||||
|
</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input
|
<input
|
||||||
id="fcNumber"
|
id="fcNumber"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
[ngModel]="fastcheckNumber()"
|
[ngModel]="fastcheckNumber()"
|
||||||
(ngModelChange)="fastcheckNumber.set($event)"
|
(ngModelChange)="onNumberChange($event)"
|
||||||
placeholder="1234-5678-0001"
|
placeholder="1234-5678-0001"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
|
maxlength="14"
|
||||||
/>
|
/>
|
||||||
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый Фастчек">Новый</a>
|
<a class="btn btn--ghost" routerLink="/new" aria-label="Создать новый fastCHECK">Новый</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -41,8 +50,12 @@
|
|||||||
step="1"
|
step="1"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
|
[readonly]="amountLoading() || fastcheckAmount() !== null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@if (amountLoading()) {
|
||||||
|
<span class="field__hint">Проверяем…</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Code -->
|
<!-- Code -->
|
||||||
@@ -53,10 +66,10 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="input"
|
||||||
[ngModel]="fastcheckCode()"
|
[ngModel]="fastcheckCode()"
|
||||||
(ngModelChange)="fastcheckCode.set($event)"
|
(ngModelChange)="onCodeChange($event)"
|
||||||
placeholder="0000"
|
placeholder="00000"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
maxlength="8"
|
maxlength="5"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
/>
|
/>
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
@@ -64,7 +77,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="pay-btn" type="button" (click)="pay()">
|
<button class="pay-btn" type="button" (click)="pay()" [disabled]="!canPay()">
|
||||||
<span class="pay-btn__icon">
|
<span class="pay-btn__icon">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@@ -101,9 +114,14 @@
|
|||||||
<path d="M20 6L9 17l-5-5" />
|
<path d="M20 6L9 17l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
<h2 class="modal__title">Оплачено</h2>
|
<h2 class="modal__title">Оплачено</h2>
|
||||||
<p class="modal__sub">Фастчек успешно принят.</p>
|
<p class="modal__sub">
|
||||||
|
<span class="brand"><span class="brand__fast">fast</span><span class="brand__check">CHECK</span></span>
|
||||||
|
успешно принят.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
<img class="brand-logo brand-logo--small" src="/logo_small.png"
|
||||||
|
alt="fastCHECK" width="32" height="32" />
|
||||||
<h2 class="modal__title">Войти через Telegram</h2>
|
<h2 class="modal__title">Войти через Telegram</h2>
|
||||||
<p class="modal__sub">Отсканируйте QR или откройте ссылку</p>
|
<p class="modal__sub">Отсканируйте QR или откройте ссылку</p>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ interface WebSessionResponse {
|
|||||||
Status: boolean;
|
Status: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CheckFastcheckResponse {
|
||||||
|
fastcheck: string;
|
||||||
|
amount?: number;
|
||||||
|
expiration: string;
|
||||||
|
Status: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-fastcheck-page',
|
selector: 'app-fastcheck-page',
|
||||||
imports: [FormsModule, RouterLink],
|
imports: [FormsModule, RouterLink],
|
||||||
@@ -31,6 +38,7 @@ export class FastcheckPage {
|
|||||||
fastcheckAmount = signal<number | null>(null);
|
fastcheckAmount = signal<number | null>(null);
|
||||||
fastcheckCode = signal<string>('');
|
fastcheckCode = signal<string>('');
|
||||||
error = signal<string>('');
|
error = signal<string>('');
|
||||||
|
amountLoading = signal<boolean>(false);
|
||||||
|
|
||||||
popupOpen = signal<boolean>(false);
|
popupOpen = signal<boolean>(false);
|
||||||
popupLoading = signal<boolean>(false);
|
popupLoading = signal<boolean>(false);
|
||||||
@@ -38,6 +46,13 @@ export class FastcheckPage {
|
|||||||
webSessionId = signal<string>('');
|
webSessionId = signal<string>('');
|
||||||
paid = signal<boolean>(false);
|
paid = signal<boolean>(false);
|
||||||
private pollHandle: ReturnType<typeof setInterval> | null = null;
|
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(() => {
|
telegramLink = computed(() => {
|
||||||
const sid = this.webSessionId();
|
const sid = this.webSessionId();
|
||||||
@@ -62,12 +77,7 @@ export class FastcheckPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pay(): void {
|
pay(): void {
|
||||||
if (!this.fastcheckNumber().trim()) {
|
if (!this.canPay()) {
|
||||||
this.error.set('Введите номер Фастчека');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.fastcheckCode().trim()) {
|
|
||||||
this.error.set('Введите код Фастчека');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
@@ -148,7 +158,7 @@ export class FastcheckPage {
|
|||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.popupLoading.set(false);
|
this.popupLoading.set(false);
|
||||||
this.popupError.set('Не удалось принять Фастчек.');
|
this.popupError.set('Не удалось принять платёж.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -168,4 +178,64 @@ export class FastcheckPage {
|
|||||||
onAmountChange(value: number | null): void {
|
onAmountChange(value: number | null): void {
|
||||||
this.fastcheckAmount.set(value);
|
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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/app/pages/legacy-pay-page/legacy-pay-page.html
Normal 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>
|
||||||
60
src/app/pages/legacy-pay-page/legacy-pay-page.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/app/pages/legacy-pay-page/legacy-pay-page.ts
Normal 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('Ошибка при создании платежа. Попробуйте ещё раз.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Оплата через СБП</title>
|
<title>fastCHECK</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/png" href="logo_small.png">
|
||||||
|
<link rel="apple-touch-icon" href="logo_small.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -58,6 +58,18 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
padding: 24px 22px 18px;
|
padding: 24px 22px 18px;
|
||||||
|
|
||||||
@@ -102,6 +114,14 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrap {
|
.input-wrap {
|
||||||
@@ -179,6 +199,7 @@
|
|||||||
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s;
|
||||||
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
|
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.38);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|
||||||
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
|
&:hover { opacity: 0.92; box-shadow: 0 8px 28px rgba(37, 99, 235, 0.45); }
|
||||||
@@ -198,3 +219,43 @@
|
|||||||
|
|
||||||
svg { flex-shrink: 0; }
|
svg { flex-shrink: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Brand wordmark: "fastCHECK" inline ─────────────────────────────────────
|
||||||
|
// "fast" sits a bit smaller and lighter than "CHECK".
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
font-weight: inherit;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: calc(1em + 3px);
|
||||||
|
|
||||||
|
&__fast {
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-right: 0.05em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__check {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone logo image (used inside modal/header)
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
|
||||||
|
&--small {
|
||||||
|
max-height: 32px;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||