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
This commit is contained in:
2026-07-23 00:26:50 +04:00
parent eba5393cc6
commit a5d42d22e7

View File

@@ -29,14 +29,15 @@ interface SettingsResponse {
interface CreateQrResponse { interface CreateQrResponse {
qrId?: string; qrId?: string;
nspkID?: string; nspkID?: string;
Payload?: string; // per API doc (capital P) Payload?: string; // per API doc (capital P)
nspkurl?: string; // actual field name in real responses nspkurl?: string; // actual field name in real responses
qrUrl?: string; qrUrl?: string;
status?: string; // e.g. "REGISTERED" status?: string; // e.g. "REGISTERED"
qrTTL?: number;
} }
interface QrStatusResponse { interface QrStatusResponse {
status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED" status?: string; // "REGISTERED" | "NEW" | "APPROVED" | "REJECTED" | "COMPLETED"
[key: string]: unknown; [key: string]: unknown;
} }
@@ -44,16 +45,18 @@ interface QrStatusResponse {
selector: 'app-create-page', selector: 'app-create-page',
imports: [FormsModule, TranslatePipe], imports: [FormsModule, TranslatePipe],
templateUrl: './create-page.html', templateUrl: './create-page.html',
styleUrl: './create-page.scss' styleUrl: './create-page.scss',
}) })
export class CreatePage { export class CreatePage {
private http = inject(HttpClient); private http = inject(HttpClient);
private i18n = inject(TranslationService); private i18n = inject(TranslationService);
private readonly sites: Record<string, string> = { private readonly sites: Record<string, string> = {
'51': 'fastcheck.store' '51': 'fastcheck.store',
}; };
private t(key: string): string { return this.i18n.translate(key); } private t(key: string): string {
return this.i18n.translate(key);
}
// Limits updated from settings API on init. // Limits updated from settings API on init.
minAmount = signal<number>(30); minAmount = signal<number>(30);
@@ -84,6 +87,9 @@ export class CreatePage {
qrStatus = signal<string>(''); qrStatus = signal<string>('');
paymentDone = signal<boolean>(false); paymentDone = signal<boolean>(false);
private pollHandle: ReturnType<typeof setInterval> | null = null; 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. */ /** Auth credentials passed by the host page as URL params. */
private get authKey(): string { private get authKey(): string {
@@ -102,7 +108,7 @@ export class CreatePage {
return new URLSearchParams(window.location.search).get('from') ?? ''; return new URLSearchParams(window.location.search).get('from') ?? '';
} }
get isMobile(): boolean { get isMobile(): boolean {
return window.innerWidth < 768; return window.innerWidth < 768;
} }
@@ -126,7 +132,7 @@ export class CreatePage {
} else { } else {
this.lockedAmount.set(null); this.lockedAmount.set(null);
} }
} },
}); });
} }
@@ -137,7 +143,9 @@ export class CreatePage {
return; return;
} }
if (val !== null && val > this.maxAmount()) { if (val !== null && val > this.maxAmount()) {
this.error.set(`${this.t('errors.invalid_amount')} (макс. ${this.maxAmount().toLocaleString('ru')} ₽)`); this.error.set(
`${this.t('errors.invalid_amount')} (макс. ${this.maxAmount().toLocaleString('ru')} ₽)`,
);
return; return;
} }
@@ -152,7 +160,7 @@ export class CreatePage {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (this.authKey) headers['authorization-key'] = this.authKey; if (this.authKey) headers['authorization-key'] = this.authKey;
if (this.userId) headers['userid-value'] = this.userId; if (this.userId) headers['userid-value'] = this.userId;
this.http this.http
.post<CreateQrResponse>( .post<CreateQrResponse>(
@@ -162,12 +170,13 @@ export class CreatePage {
...(val !== null ? { amount: val } : {}), ...(val !== null ? { amount: val } : {}),
currency: this.currency(), currency: this.currency(),
partnerqrID, partnerqrID,
qrDescription: this.note().trim(), qrDescription: this.note().trim() || 'Покупка на Маркетплейсе',
customerID: this.userId,
Userid: this.userId, Userid: this.userId,
Reference: this.reference, Reference: this.reference,
RedirectUrl: `https://fastcheck.store?id=fast-c202-4062-bcfb-8b4c8cc59adc` RedirectUrl: `https://fastcheck.store?id=fast-c202-4062-bcfb-8b4c8cc59adc`,
}, },
{ headers } { headers },
) )
.subscribe({ .subscribe({
next: (res) => { next: (res) => {
@@ -187,7 +196,7 @@ export class CreatePage {
? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}` ? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(nspkUrl)}`
: (res.qrUrl ?? null); : (res.qrUrl ?? null);
this.qrImageUrl.set(qrData); this.qrImageUrl.set(qrData);
if (qrId) this.startPolling(qrId); if (qrId) this.startPolling(qrId, res?.qrTTL);
} else { } else {
this.error.set(this.t('errors.payment_failed')); this.error.set(this.t('errors.payment_failed'));
} }
@@ -196,15 +205,30 @@ export class CreatePage {
this.loading.set(false); this.loading.set(false);
const msg: string | undefined = err?.error?.message; const msg: string | undefined = err?.error?.message;
this.error.set(msg ?? this.t('errors.lookup_failed')); this.error.set(msg ?? this.t('errors.lookup_failed'));
} },
}); });
} }
private startPolling(qrId: string): void { private startPolling(qrId: string, qrTTL?: number): void {
this.stopPolling(); this.stopPolling();
this.qrPolling.set(true); 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(() => { this.pollHandle = setInterval(() => {
this.http.get<QrStatusResponse>(`${QR_VITANOVA_API}/qr/dynamic/${encodeURIComponent(this.partnerqrID)}/${qrId}`) 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({ .subscribe({
next: (res) => { next: (res) => {
const st = res?.status ?? ''; const st = res?.status ?? '';
@@ -221,9 +245,9 @@ export class CreatePage {
error: () => { error: () => {
this.closeQr(); this.closeQr();
this.error.set('оплата не прошла'); this.error.set('оплата не прошла');
} },
}); });
}, 5000); }, CreatePage.POLL_INTERVAL_MS);
} }
private stopPolling(): void { private stopPolling(): void {
@@ -268,7 +292,7 @@ export class CreatePage {
.post(`https://fastcheck.store/api/fastcheck/settings/${encodeURIComponent(id)}`, paidQr) .post(`https://fastcheck.store/api/fastcheck/settings/${encodeURIComponent(id)}`, paidQr)
.subscribe({ .subscribe({
next: () => this.redirectToSource(id), next: () => this.redirectToSource(id),
error: () => this.redirectToSource(id) error: () => this.redirectToSource(id),
}); });
} }