Files
qr_vitanova/src/app/pages/create-page/create-page.ts
sdarbinyan a5d42d22e7 feat(payment): add customerID field, TTL-based QR polling
- POST /qr body gains customerID (telegram id), qrDescription now
  guaranteed non-empty (fallback text when note is blank)
- CreateQrResponse gains qrTTL; polling now bounded by it (min 60s)
  instead of running unbounded via setInterval
2026-07-23 00:26:50 +04:00

328 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { QR_VITANOVA_API } from '../../api';
import { TranslatePipe } from '../../translate/translate.pipe';
import { TranslationService } from '../../translate/translation.service';
type PaymentMethod = 'sbp';
type Currency = 'RUB';
interface SettingsResponse {
sbp?: boolean;
wechat?: boolean;
visa?: boolean;
mastercard?: boolean;
alipay?: boolean;
rubles?: boolean;
usd?: boolean;
euro?: boolean;
cny?: boolean;
dram?: boolean;
minAmount?: number;
maxAmount?: number;
qrTTL?: number;
defaultAmount?: number;
enableAmount?: boolean;
}
interface CreateQrResponse {
qrId?: string;
nspkID?: string;
Payload?: string; // per API doc (capital P)
nspkurl?: string; // actual field name in real responses
qrUrl?: string;
status?: string; // e.g. "REGISTERED"
qrTTL?: number;
}
interface QrStatusResponse {
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
[key: string]: unknown;
}
@Component({
selector: 'app-create-page',
imports: [FormsModule, TranslatePipe],
templateUrl: './create-page.html',
styleUrl: './create-page.scss',
})
export class CreatePage {
private http = inject(HttpClient);
private i18n = inject(TranslationService);
private readonly sites: Record<string, string> = {
'51': 'fastcheck.store',
};
private t(key: string): string {
return this.i18n.translate(key);
}
// Limits updated from settings API on init.
minAmount = signal<number>(30);
maxAmount = signal<number>(200_000);
amount = signal<number | null>(null);
private lockedAmount = signal<number | null>(null);
note = signal<string>('');
error = signal<string>('');
loading = signal<boolean>(false);
currency = signal<Currency>('RUB');
payment = signal<PaymentMethod>('sbp');
selectPayment(method: PaymentMethod, enabled: boolean): void {
if (!enabled) return;
this.payment.set(method);
}
selectCurrency(c: Currency, enabled: boolean): void {
if (!enabled) return;
this.currency.set(c);
}
// QR display state
qrImageUrl = signal<string | null>(null);
qrPolling = signal<boolean>(false);
qrStatus = signal<string>('');
paymentDone = signal<boolean>(false);
private pollHandle: ReturnType<typeof setInterval> | null = null;
private static readonly POLL_INTERVAL_MS = 5000;
private static readonly MIN_POLL_SECONDS = 60;
private pollChecksRemaining = 0;
/** Auth credentials passed by the host page as URL params. */
private get authKey(): string {
return new URLSearchParams(window.location.search).get('authorization-key') ?? '';
}
private get userId(): string {
return new URLSearchParams(window.location.search).get('userid-value') ?? '';
}
private get reference(): string {
return new URLSearchParams(window.location.search).get('ref') ?? window.location.hostname;
}
private get partnerqrID(): string {
return new URLSearchParams(window.location.search).get('id') ?? '';
}
private get fromSite(): string {
return new URLSearchParams(window.location.search).get('from') ?? '';
}
get isMobile(): boolean {
return window.innerWidth < 768;
}
constructor() {
this.loadSettings();
}
private loadSettings(): void {
// Fetch limits from /qr/settings. If the call fails, keep defaults.
const partnerqrID = this.partnerqrID;
const url = `${QR_VITANOVA_API}/qr/settings${partnerqrID ? `/${encodeURIComponent(partnerqrID)}` : ''}`;
this.http.get<SettingsResponse>(url).subscribe({
next: (s) => {
if (typeof s?.minAmount === 'number') this.minAmount.set(s.minAmount);
if (typeof s?.maxAmount === 'number') this.maxAmount.set(s.maxAmount);
const defaultAmount = typeof s?.defaultAmount === 'number' ? s.defaultAmount : 0;
if (s?.enableAmount === true && defaultAmount > 0) {
this.lockedAmount.set(defaultAmount);
this.amount.set(defaultAmount);
} else {
this.lockedAmount.set(null);
}
},
});
}
createCheck(): void {
const val = this.lockedAmount() ?? this.amount();
if (val !== null && val < this.minAmount()) {
this.error.set(`${this.t('errors.invalid_amount')} (мин. ${this.minAmount()} ₽)`);
return;
}
if (val !== null && val > this.maxAmount()) {
this.error.set(
`${this.t('errors.invalid_amount')} (макс. ${this.maxAmount().toLocaleString('ru')} ₽)`,
);
return;
}
const partnerqrID = this.partnerqrID;
if (!partnerqrID) {
this.error.set(this.t('errors.lookup_failed'));
return;
}
this.error.set('');
this.loading.set(true);
const headers: Record<string, string> = {};
if (this.authKey) headers['authorization-key'] = this.authKey;
if (this.userId) headers['userid-value'] = this.userId;
this.http
.post<CreateQrResponse>(
`${QR_VITANOVA_API}/qr`,
{
qrtype: 'QRDynamic',
...(val !== null ? { amount: val } : {}),
currency: this.currency(),
partnerqrID,
qrDescription: this.note().trim() || 'Покупка на Маркетплейсе',
customerID: this.userId,
Userid: this.userId,
Reference: this.reference,
RedirectUrl: `https://fastcheck.store?id=fast-c202-4062-bcfb-8b4c8cc59adc`,
},
{ headers },
)
.subscribe({
next: (res) => {
this.loading.set(false);
const qrId = res?.qrId ?? res?.nspkID ?? '';
// Real API uses 'nspkurl'; doc says 'Payload' — try both
const nspkUrl = res?.nspkurl ?? res?.Payload;
this.qrStatus.set(res?.status ?? '');
if (nspkUrl && this.isMobile) {
window.location.href = nspkUrl;
return;
}
if (qrId || nspkUrl) {
const qrData = nspkUrl
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
: (res.qrUrl ?? null);
this.qrImageUrl.set(qrData);
if (qrId) this.startPolling(qrId, res?.qrTTL);
} else {
this.error.set(this.t('errors.payment_failed'));
}
},
error: (err) => {
this.loading.set(false);
const msg: string | undefined = err?.error?.message;
this.error.set(msg ?? this.t('errors.lookup_failed'));
},
});
}
private startPolling(qrId: string, qrTTL?: number): void {
this.stopPolling();
this.qrPolling.set(true);
const pollSeconds = Math.max(CreatePage.MIN_POLL_SECONDS, (qrTTL ?? 0) * 60);
this.pollChecksRemaining = Math.ceil(pollSeconds / (CreatePage.POLL_INTERVAL_MS / 1000));
this.pollHandle = setInterval(() => {
if (this.pollChecksRemaining <= 0) {
this.stopPolling();
this.error.set(this.t('errors.payment_failed'));
this.qrImageUrl.set(null);
return;
}
this.pollChecksRemaining -= 1;
this.http
.get<QrStatusResponse>(
`${QR_VITANOVA_API}/qr/dynamic/${encodeURIComponent(this.partnerqrID)}/${qrId}`,
)
.subscribe({
next: (res) => {
const st = res?.status ?? '';
this.qrStatus.set(st);
if (st === 'COMPLETED' || st === 'APPROVED') {
this.handlePaymentSuccess(res);
} else if (st === 'REJECTED') {
this.stopPolling();
this.error.set(this.t('errors.payment_failed'));
this.qrImageUrl.set(null);
}
// REGISTERED / NEW / '' — keep polling
},
error: () => {
this.closeQr();
this.error.set('оплата не прошла');
},
});
}, CreatePage.POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollHandle !== null) {
clearInterval(this.pollHandle);
this.pollHandle = null;
}
this.qrPolling.set(false);
}
onAmountChange(value: number | null): void {
if (this.lockedAmount() !== null) {
this.amount.set(this.lockedAmount());
return;
}
this.amount.set(value || null);
if (value && value > 0) this.error.set('');
}
isAmountLocked(): boolean {
return this.lockedAmount() !== null;
}
onNoteChange(value: string): void {
this.note.set(value);
}
private handlePaymentSuccess(paidQr: QrStatusResponse): void {
this.stopPolling();
this.qrImageUrl.set(null);
this.qrStatus.set('');
this.paymentDone.set(true);
const id = this.partnerqrID;
if (!id) {
this.redirectToSource();
return;
}
this.http
.post(`https://fastcheck.store/api/fastcheck/settings/${encodeURIComponent(id)}`, paidQr)
.subscribe({
next: () => this.redirectToSource(id),
error: () => this.redirectToSource(id),
});
}
private redirectToSource(id?: string): void {
const withId = (target: string): string => {
if (!id) return target;
const normalizedTarget = /^https?:\/\//i.test(target) ? target : `https://${target}`;
const url = new URL(normalizedTarget);
url.searchParams.set('id', id);
return url.toString();
};
const from = this.fromSite.trim();
const target = this.sites[from];
if (target) {
window.location.href = withId(target);
return;
}
if (window.history.length > 1) {
window.history.back();
}
}
closeQr(): void {
this.stopPolling();
this.qrImageUrl.set(null);
this.qrStatus.set('');
this.paymentDone.set(false);
}
}