feat(payment): add qrDescription/customerID fields, TTL-based QR polling
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- CartPaymentRequest gains qrDescription (brandName > hostname > fallback text) and customerID (telegram id) - QrCreateResponse gains qrTTL; polling window now derived from it (min 60s) instead of a fixed 3-minute/36-check cap - PAYMENT_MAX_CHECKS replaced by PAYMENT_MIN_POLL_SECONDS
This commit is contained in:
@@ -1153,8 +1153,8 @@ Payment/QR types (`src/app/services/api.service.ts` lines 10–66, frozen — §
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface QrCreateRequest { qrtype: 'QRDynamic'; amount: number; currency: 'RUB'; partnerqrID?: string; qrDescription?: string; Userid?: string; Reference?: string; RedirectUrl?: string; }
|
interface QrCreateRequest { qrtype: 'QRDynamic'; amount: number; currency: 'RUB'; partnerqrID?: string; qrDescription?: string; Userid?: string; Reference?: string; RedirectUrl?: string; }
|
||||||
interface QrCreateResponse { qrId?: string; qrID?: string; nspkID?: string; nspkId?: string; nspkurl?: string; orderID?: string; url?: string; bankUrl?: string; status?: string; qrStatus?: string; qrExpirationDate?: string; payload?: string; Payload?: string; qrUrl?: string; partnerqrID?: string|number; partnerID?: string|number; partnerId?: string|number; PartnerID?: string|number; }
|
interface QrCreateResponse { qrId?: string; qrID?: string; nspkID?: string; nspkId?: string; nspkurl?: string; orderID?: string; url?: string; bankUrl?: string; status?: string; qrStatus?: string; qrExpirationDate?: string; qrTTL?: number; payload?: string; Payload?: string; qrUrl?: string; partnerqrID?: string|number; partnerID?: string|number; partnerId?: string|number; PartnerID?: string|number; }
|
||||||
interface CartPaymentRequest { amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; paymentMethod: 'qr'|'card'; items: Array<{itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[]}>; }
|
interface CartPaymentRequest { amount: number; currency: 'RUB'; siteuserID: string; siteorderID: string; redirectUrl: string; telegramUsername: string; paymentMethod: 'qr'|'card'; qrDescription?: string; customerID?: string; items: Array<{itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[]}>; }
|
||||||
interface QrDynamicStatusResponse { additionalInfo: string; paymentPurpose: string; amount: number; code: string; createDate: string; currency: string; order: string; status: string; qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string; }
|
interface QrDynamicStatusResponse { additionalInfo: string; paymentPurpose: string; amount: number; code: string; createDate: string; currency: string; order: string; status: string; qrId: string; transactionDate: string; transactionId: number; qrExpirationDate: string; }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Payment polling
|
// Payment polling
|
||||||
export const PAYMENT_POLL_INTERVAL_MS = 5000;
|
export const PAYMENT_POLL_INTERVAL_MS = 5000;
|
||||||
export const PAYMENT_MAX_CHECKS = 36;
|
export const PAYMENT_MIN_POLL_SECONDS = 60;
|
||||||
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
|
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
|
||||||
export const LINK_COPIED_DURATION_MS = 2000;
|
export const LINK_COPIED_DURATION_MS = 2000;
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
|
|||||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { TranslateService } from '../../i18n/translate.service';
|
import { TranslateService } from '../../i18n/translate.service';
|
||||||
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MIN_POLL_SECONDS, PAYMENT_TIMEOUT_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
||||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||||
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
||||||
|
|
||||||
type PaymentMethod = 'qr' | 'card';
|
type PaymentMethod = 'qr' | 'card';
|
||||||
|
|
||||||
@@ -65,10 +67,13 @@ export class CartComponent implements OnDestroy {
|
|||||||
emailSubmitting = signal<boolean>(false);
|
emailSubmitting = signal<boolean>(false);
|
||||||
paidItems: CartItem[] = [];
|
paidItems: CartItem[] = [];
|
||||||
|
|
||||||
maxChecks = PAYMENT_MAX_CHECKS;
|
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
||||||
private pollingSubscription?: Subscription;
|
private pollingSubscription?: Subscription;
|
||||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
private configService = inject(ConfigService);
|
||||||
|
private tenantResolver = inject(TenantResolverService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cartService: CartService,
|
private cartService: CartService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
@@ -243,6 +248,8 @@ export class CartComponent implements OnDestroy {
|
|||||||
redirectUrl: '',
|
redirectUrl: '',
|
||||||
telegramUsername: this.getTelegramUsername(),
|
telegramUsername: this.getTelegramUsername(),
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
|
qrDescription: this.getPaymentDescription(),
|
||||||
|
customerID: this.getTelegramUserId() ?? undefined,
|
||||||
items: this.buildPaymentItems(),
|
items: this.buildPaymentItems(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,7 +273,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.bankPaymentUrl.set(bankUrl);
|
this.bankPaymentUrl.set(bankUrl);
|
||||||
|
|
||||||
this.paymentStatus.set('waiting');
|
this.paymentStatus.set('waiting');
|
||||||
this.startPolling();
|
this.startPolling(response.qrTTL);
|
||||||
if (paymentMethod === 'card') {
|
if (paymentMethod === 'card') {
|
||||||
this.openBankPaymentPopup();
|
this.openBankPaymentPopup();
|
||||||
}
|
}
|
||||||
@@ -278,16 +285,19 @@ export class CartComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
startPolling(): void {
|
startPolling(qrTTL?: number): void {
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
if (!this.paymentId()) {
|
if (!this.paymentId()) {
|
||||||
this.setPaymentError();
|
this.setPaymentError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60);
|
||||||
|
this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
||||||
|
|
||||||
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||||
.pipe(
|
.pipe(
|
||||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute
|
||||||
exhaustMap(() => {
|
exhaustMap(() => {
|
||||||
const statusRequest = this.selectedPaymentMethod() === 'card'
|
const statusRequest = this.selectedPaymentMethod() === 'card'
|
||||||
? this.apiService.checkCartCardPaymentStatus(this.paymentId())
|
? this.apiService.checkCartCardPaymentStatus(this.paymentId())
|
||||||
@@ -568,6 +578,20 @@ export class CartComponent implements OnDestroy {
|
|||||||
return this.getTelegramUserId() ?? `web_${Date.now()}`;
|
return this.getTelegramUserId() ?? `web_${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPaymentDescription(): string {
|
||||||
|
const brandName = this.configService.getBootstrapSnapshot()?.branding?.brandName?.trim();
|
||||||
|
if (brandName) {
|
||||||
|
return brandName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = this.tenantResolver.getHostname();
|
||||||
|
if (hostname && !this.tenantResolver.isLocalhost()) {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Покупка на Маркетплейсе';
|
||||||
|
}
|
||||||
|
|
||||||
private generateOrderId(): string {
|
private generateOrderId(): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface QrCreateResponse {
|
|||||||
status?: string;
|
status?: string;
|
||||||
qrStatus?: string;
|
qrStatus?: string;
|
||||||
qrExpirationDate?: string;
|
qrExpirationDate?: string;
|
||||||
|
qrTTL?: number;
|
||||||
payload?: string;
|
payload?: string;
|
||||||
Payload?: string;
|
Payload?: string;
|
||||||
qrUrl?: string;
|
qrUrl?: string;
|
||||||
@@ -47,6 +48,8 @@ export interface CartPaymentRequest {
|
|||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
telegramUsername: string;
|
telegramUsername: string;
|
||||||
paymentMethod: 'qr' | 'card';
|
paymentMethod: 'qr' | 'card';
|
||||||
|
qrDescription?: string;
|
||||||
|
customerID?: string;
|
||||||
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
|
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user