feat(packages): add standalone auth and payment
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
// @marketplaces/payment — public API barrel.
|
||||
// Scaffold only: code migrates here from src/app/core/finance and src/app/core/pricing
|
||||
// per ADR-0001 (marketplaces repo: docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md).
|
||||
// Nothing is exported yet — the marketplaces repo still owns the live implementation until migration lands.
|
||||
export {};
|
||||
export { MarketplacesPaymentComponent } from './payment.component';
|
||||
export { MARKETPLACES_PAYMENT_CONFIG, provideMarketplacesPayment } from './payment.config';
|
||||
export type { MarketplacesPaymentConfig } from './payment.config';
|
||||
export { MARKETPLACE_DOMAIN_HEADER, PaymentMarketplaceContext, normalizeMarketplaceDomain } from './marketplace-context';
|
||||
export { MARKETPLACES_PAYMENT_GATEWAY, HttpMarketplacesPaymentGateway } from './payment.gateway';
|
||||
export type { MarketplacesPaymentGateway } from './payment.gateway';
|
||||
export type { PaymentMethod, PaymentStatus, PaymentRequest, PaymentAttempt, PaymentResult, PaymentFailure } from './payment.models';
|
||||
|
||||
22
packages/payment/src/marketplace-context.ts
Normal file
22
packages/payment/src/marketplace-context.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { HttpHeaders } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { MARKETPLACES_PAYMENT_CONFIG } from './payment.config';
|
||||
|
||||
export const MARKETPLACE_DOMAIN_HEADER = 'X-Marketplace-Domain';
|
||||
export function normalizeMarketplaceDomain(domain: string): string {
|
||||
return domain.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PaymentMarketplaceContext {
|
||||
private readonly config = inject(MARKETPLACES_PAYMENT_CONFIG);
|
||||
domain(): string {
|
||||
const configured = this.config.marketplaceDomain;
|
||||
const domain = typeof configured === 'function' ? configured() : configured ?? (typeof location === 'undefined' ? '' : location.hostname);
|
||||
return normalizeMarketplaceDomain(domain);
|
||||
}
|
||||
headers(): HttpHeaders {
|
||||
const domain = this.domain();
|
||||
if (!domain) throw new Error('Marketplace domain cannot be resolved');
|
||||
return new HttpHeaders({ [MARKETPLACE_DOMAIN_HEADER]: domain });
|
||||
}
|
||||
}
|
||||
92
packages/payment/src/payment.component.ts
Normal file
92
packages/payment/src/payment.component.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Component, DestroyRef, booleanAttribute, inject, input, output, signal } from '@angular/core';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { Subscription, switchMap, timer } from 'rxjs';
|
||||
import { MARKETPLACES_PAYMENT_CONFIG } from './payment.config';
|
||||
import { MARKETPLACES_PAYMENT_GATEWAY } from './payment.gateway';
|
||||
import { PaymentAttempt, PaymentFailure, PaymentMethod, PaymentRequest, PaymentResult } from './payment.models';
|
||||
|
||||
@Component({
|
||||
selector: 'mp-payment, marketplaces-payment',
|
||||
standalone: true,
|
||||
template: `
|
||||
<section class="mp-payment" aria-labelledby="mp-payment-title">
|
||||
<h2 id="mp-payment-title">{{ title() }}</h2>
|
||||
<div class="methods">
|
||||
@if (qr()) { <button type="button" (click)="pay('qr')" [disabled]="busy()">QR</button> }
|
||||
@if (card()) { <button type="button" (click)="pay('card')" [disabled]="busy()">Карта</button> }
|
||||
@if (sbp()) { <button type="button" (click)="pay('sbp')" [disabled]="busy()">СБП</button> }
|
||||
@if (yandexPay()) { <button type="button" (click)="pay('yandex-pay')" [disabled]="busy()">Яндекс Pay</button> }
|
||||
</div>
|
||||
@if (qrImage()) { <a [href]="actionUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код оплаты" /></a> }
|
||||
@if (busy()) { <p role="status">Проверяем оплату…</p> }
|
||||
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
|
||||
@if (attempt()) { <button type="button" class="cancel" (click)="cancel()">Отменить</button> }
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
:host{display:block}.mp-payment{font:inherit;color:inherit;display:grid;gap:1rem;max-width:26rem}
|
||||
h2,p{margin:0}.methods{display:flex;gap:.6rem;flex-wrap:wrap}button{font:inherit;cursor:pointer;border:1px solid #111;border-radius:.65rem;padding:.75rem 1rem;background:#111;color:#fff}
|
||||
button:disabled{opacity:.55;cursor:wait}.cancel{background:transparent;color:inherit;border-color:#c7c7c7}img{display:block;width:min(15rem,100%);height:auto}.error{color:#b42318}
|
||||
`],
|
||||
})
|
||||
export class MarketplacesPaymentComponent {
|
||||
readonly qr = input(false, { transform: booleanAttribute });
|
||||
readonly card = input(false, { transform: booleanAttribute });
|
||||
readonly sbp = input(false, { transform: booleanAttribute });
|
||||
readonly yandexPay = input(false, { transform: booleanAttribute });
|
||||
readonly request = input.required<PaymentRequest>();
|
||||
readonly title = input('Оплата');
|
||||
readonly completed = output<PaymentResult>();
|
||||
readonly paymentError = output<PaymentFailure>();
|
||||
readonly cancelled = output<void>();
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal<PaymentFailure | null>(null);
|
||||
readonly attempt = signal<PaymentAttempt | null>(null);
|
||||
readonly qrImage = signal<string | null>(null);
|
||||
readonly actionUrl = signal<string | null>(null);
|
||||
private readonly gateway = inject(MARKETPLACES_PAYMENT_GATEWAY);
|
||||
private readonly config = inject(MARKETPLACES_PAYMENT_CONFIG);
|
||||
private poll?: Subscription;
|
||||
|
||||
constructor() { inject(DestroyRef).onDestroy(() => this.poll?.unsubscribe()); }
|
||||
pay(method: PaymentMethod): void {
|
||||
this.poll?.unsubscribe(); this.error.set(null); this.busy.set(true); this.qrImage.set(null);
|
||||
this.gateway.create(method, this.request()).subscribe({
|
||||
next: created => void this.handleCreated(created).catch(cause => this.fail(method, cause)),
|
||||
error: cause => this.fail(method, cause),
|
||||
});
|
||||
}
|
||||
cancel(): void {
|
||||
const current = this.attempt();
|
||||
this.poll?.unsubscribe(); this.busy.set(false); this.attempt.set(null);
|
||||
if (current) this.gateway.cancel(current.paymentId).subscribe({ error: () => undefined });
|
||||
this.cancelled.emit();
|
||||
}
|
||||
private async handleCreated(created: PaymentAttempt): Promise<void> {
|
||||
this.attempt.set(created);
|
||||
if (created.action?.url) {
|
||||
this.actionUrl.set(created.action.url);
|
||||
if (created.action.type === 'qr') this.qrImage.set(await QRCode.toDataURL(created.action.url, { width: 320, margin: 1 }));
|
||||
else if (typeof window !== 'undefined' && !window.open(created.action.url, '_blank', 'noopener')) {
|
||||
this.fail(created.method, { method: created.method, code: 'popup_blocked', message: 'Браузер заблокировал окно оплаты' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (created.status === 'paid' || created.status === 'authorized') { this.finish(created); return; }
|
||||
if (created.status === 'failed' || created.status === 'expired') { this.fail(created.method, created.failureMessage ?? 'Платёж отклонён'); return; }
|
||||
this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.status(created.paymentId, created.method)))
|
||||
.subscribe({ next: status => this.handleStatus(status), error: cause => this.fail(created.method, cause) });
|
||||
}
|
||||
private handleStatus(current: PaymentAttempt): void {
|
||||
this.attempt.set(current);
|
||||
if (current.status === 'paid' || current.status === 'authorized') this.finish(current);
|
||||
else if (current.status === 'failed' || current.status === 'expired' || current.status === 'cancelled') this.fail(current.method, current.failureMessage ?? 'Платёж не завершён');
|
||||
}
|
||||
private finish(attempt: PaymentAttempt): void { this.poll?.unsubscribe(); this.busy.set(false); this.attempt.set(null); this.completed.emit(attempt as PaymentResult); }
|
||||
private fail(method: PaymentMethod, cause: unknown): void {
|
||||
this.poll?.unsubscribe(); this.busy.set(false);
|
||||
const failure: PaymentFailure = this.isFailure(cause) ? cause : { method, code: 'backend', message: typeof cause === 'string' ? cause : 'Не удалось провести платёж', cause };
|
||||
this.error.set(failure); this.paymentError.emit(failure);
|
||||
}
|
||||
private isFailure(value: unknown): value is PaymentFailure { return !!value && typeof value === 'object' && 'code' in value && 'message' in value; }
|
||||
}
|
||||
21
packages/payment/src/payment.config.ts
Normal file
21
packages/payment/src/payment.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
||||
|
||||
export interface MarketplacesPaymentConfig {
|
||||
/** Central payment service URL. It is not the tenant API URL. */
|
||||
apiUrl: string;
|
||||
marketplaceDomain?: string | (() => string);
|
||||
paymentsPath?: string;
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
export const MARKETPLACES_PAYMENT_CONFIG = new InjectionToken<MarketplacesPaymentConfig>('@marketplaces/payment config');
|
||||
export function provideMarketplacesPayment(config: MarketplacesPaymentConfig): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([{
|
||||
provide: MARKETPLACES_PAYMENT_CONFIG,
|
||||
useValue: {
|
||||
...config,
|
||||
apiUrl: config.apiUrl.replace(/\/$/, ''),
|
||||
paymentsPath: config.paymentsPath ?? '/api/v1/payments',
|
||||
pollIntervalMs: config.pollIntervalMs ?? 1500,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
44
packages/payment/src/payment.gateway.ts
Normal file
44
packages/payment/src/payment.gateway.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { Observable, catchError, throwError } from 'rxjs';
|
||||
import { PaymentMarketplaceContext } from './marketplace-context';
|
||||
import { MARKETPLACES_PAYMENT_CONFIG } from './payment.config';
|
||||
import { PaymentAttempt, PaymentFailure, PaymentMethod, PaymentRequest } from './payment.models';
|
||||
|
||||
export interface MarketplacesPaymentGateway {
|
||||
create(method: PaymentMethod, request: PaymentRequest): Observable<PaymentAttempt>;
|
||||
status(paymentId: string, method: PaymentMethod): Observable<PaymentAttempt>;
|
||||
cancel(paymentId: string): Observable<void>;
|
||||
}
|
||||
export const MARKETPLACES_PAYMENT_GATEWAY = new InjectionToken<MarketplacesPaymentGateway>(
|
||||
'@marketplaces/payment gateway', { providedIn: 'root', factory: () => inject(HttpMarketplacesPaymentGateway) }
|
||||
);
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HttpMarketplacesPaymentGateway implements MarketplacesPaymentGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly config = inject(MARKETPLACES_PAYMENT_CONFIG);
|
||||
private readonly context = inject(PaymentMarketplaceContext);
|
||||
create(method: PaymentMethod, request: PaymentRequest): Observable<PaymentAttempt> {
|
||||
return this.http.post<PaymentAttempt>(this.baseUrl(), { ...request, method }, { headers: this.context.headers() }).pipe(this.handle(method));
|
||||
}
|
||||
status(paymentId: string, method: PaymentMethod): Observable<PaymentAttempt> {
|
||||
return this.http.get<PaymentAttempt>(`${this.baseUrl()}/${encodeURIComponent(paymentId)}`, { headers: this.context.headers() }).pipe(this.handle(method));
|
||||
}
|
||||
cancel(paymentId: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl()}/${encodeURIComponent(paymentId)}/cancel`, {}, { headers: this.context.headers() });
|
||||
}
|
||||
private baseUrl(): string {
|
||||
const path = this.config.paymentsPath ?? '';
|
||||
return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
private handle<T>(method: PaymentMethod) {
|
||||
return (source: Observable<T>) => source.pipe(catchError(cause => {
|
||||
const response = cause instanceof HttpErrorResponse ? cause : null;
|
||||
const failure: PaymentFailure = {
|
||||
method, code: response?.status === 402 ? 'declined' : 'backend',
|
||||
message: response?.error?.message || response?.message || 'Payment failed', cause,
|
||||
};
|
||||
return throwError(() => failure);
|
||||
}));
|
||||
}
|
||||
}
|
||||
18
packages/payment/src/payment.models.ts
Normal file
18
packages/payment/src/payment.models.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type PaymentMethod = 'qr' | 'card' | 'sbp' | 'yandex-pay';
|
||||
export type PaymentStatus = 'created' | 'pending' | 'authorized' | 'paid' | 'failed' | 'cancelled' | 'expired';
|
||||
/** Central payment API resolves amount/currency/routing from this server checkout id. */
|
||||
export interface PaymentRequest { checkoutSessionId: string; returnUrl?: string; metadata?: Readonly<Record<string, string>>; }
|
||||
export interface PaymentAttempt {
|
||||
paymentId: string;
|
||||
method: PaymentMethod;
|
||||
status: PaymentStatus;
|
||||
action?: { type: 'redirect' | 'qr'; url: string };
|
||||
failureMessage?: string;
|
||||
}
|
||||
export interface PaymentResult extends PaymentAttempt { status: 'paid' | 'authorized'; }
|
||||
export interface PaymentFailure {
|
||||
method: PaymentMethod;
|
||||
code: 'configuration' | 'backend' | 'declined' | 'expired' | 'popup_blocked';
|
||||
message: string;
|
||||
cause?: unknown;
|
||||
}
|
||||
Reference in New Issue
Block a user