feat(packages): add standalone auth and payment
Some checks failed
Release / release-branches (auth) (push) Has been cancelled
Release / release-branches (payment) (push) Has been cancelled
Release / version-pr (push) Has been cancelled

This commit is contained in:
2026-08-21 08:08:42 +04:00
parent 216d376167
commit 5ffc1b1450
28 changed files with 5855 additions and 26 deletions

View File

@@ -0,0 +1,6 @@
---
"@marketplaces/auth": minor
"@marketplaces/payment": minor
---
Add standalone central-API Angular auth and payment components with domain project context.

View File

@@ -2,8 +2,30 @@
Shared client packages consumed by `marketplaces` and other projects. Shared client packages consumed by `marketplaces` and other projects.
- `packages/auth``@marketplaces/auth`. Real implementation. Two independent mechanisms: `telegram/` (live QR/session auth, customer + admin) and `ed25519/` (challenge/response admin auth, backend not shipped yet). - `packages/auth``@marketplaces/auth`. Standalone Angular UI + gateway for QR, credentials and Yandex auth.
- `packages/payment``@marketplaces/payment`. Scaffold only, no implementation yet. - `packages/payment``@marketplaces/payment`. Standalone Angular UI + gateway for QR, card, SBP and Yandex Pay.
The packages call separate central services. They never derive or call the tenant API. Each request automatically includes the page hostname as `X-Marketplace-Domain`; central Auth/Payment APIs resolve that domain to the project.
```ts
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideMarketplacesAuth({ apiUrl: 'https://auth.example.net' }),
provideMarketplacesPayment({ apiUrl: 'https://payments.example.net' }),
],
});
```
```html
<mp-auth qr credentials yandex mode="admin"
(authenticated)="onLogin($event)" />
<mp-payment qr card sbp yandexPay [request]="checkout"
(completed)="onPaid($event)" />
```
Import `MarketplacesAuthComponent` / `MarketplacesPaymentComponent` into the consuming standalone component. See `docs/BACKEND-CONTRACT.md` for central API and CORS requirements.
Consumer documentation lives in the `marketplaces` repo: `docs/PACKAGES-USAGE.md`. Rationale: `docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md`. Consumer documentation lives in the `marketplaces` repo: `docs/PACKAGES-USAGE.md`. Rationale: `docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md`.

47
docs/BACKEND-CONTRACT.md Normal file
View File

@@ -0,0 +1,47 @@
# Backend contract for `@marketplaces/auth` and `@marketplaces/payment`
These packages call two independent central services, never the tenant API:
- `provideMarketplacesAuth({ apiUrl })` points to the central Auth API.
- `provideMarketplacesPayment({ apiUrl })` points to the central Payment API.
Every request carries `X-Marketplace-Domain` with the full page hostname. Example: both `example.com` and `store1.example.com` are sent verbatim. The central backend resolves both aliases through its domain registry to the same project. This header is untrusted routing context, not authorization.
Both services must allow `X-Marketplace-Domain` in CORS preflight and reject unknown/disabled domains.
## Auth API
Telegram QR:
- `POST /users/sessions`
- `GET /users/sessions/:id`
- `DELETE /users/sessions/:id`
Credentials:
```http
POST /auth/credentials/login
X-Marketplace-Domain: store1.example.com
Content-Type: application/json
{ "login": "admin", "password": "...", "mode": "admin" }
```
Return `{ method, mode, session, accessToken?, refreshToken? }`. Invalid credentials return `401`.
Yandex OAuth is backend-owned:
- `POST /auth/yandex/sessions` with `{ provider: "yandex", mode, returnUrl }` returns `{ attemptId, authorizationUrl }`.
- `GET /auth/yandex/sessions/:attemptId` returns `202`/`404` while pending and the same auth result when complete.
Yandex client secrets never enter the browser. For `mode=admin`, the Auth API enforces admin authorization.
## Payment API
- `POST /api/v1/payments` with `{ checkoutSessionId, method, returnUrl?, metadata? }`.
- `GET /api/v1/payments/:paymentId`.
- `POST /api/v1/payments/:paymentId/cancel`.
The Payment API uses `X-Marketplace-Domain` to resolve the project/payment point. The browser sends no amount or currency; it sends only the central checkout session id. Server resolves and freezes amount, currency, inventory, project, provider and idempotency.
Methods: `qr`, `card`, `sbp`, `yandex-pay`. Response action is `{ type: "qr" | "redirect", url }`. Provider secrets, card data and callbacks stay server-side.

5193
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,17 @@
"release": "npm run build && changeset publish" "release": "npm run build && changeset publish"
}, },
"devDependencies": { "devDependencies": {
"@angular/common": "22.0.8",
"@angular/compiler": "22.0.8",
"@angular/compiler-cli": "22.0.8",
"@angular/core": "22.0.8",
"@angular/forms": "22.0.8",
"@angular/router": "22.0.8",
"@changesets/cli": "^2.27.0", "@changesets/cli": "^2.27.0",
"@types/qrcode": "^1.5.5",
"ng-packagr": "22.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.8.0",
"typescript": "~6.0.3" "typescript": "~6.0.3"
} }
} }

View File

@@ -0,0 +1,6 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "dist",
"allowedNonPeerDependencies": ["qrcode"],
"lib": { "entryFile": "src/index.ts" }
}

View File

@@ -1,17 +1,23 @@
{ {
"name": "@marketplaces/auth", "name": "@marketplaces/auth",
"version": "0.1.0", "version": "0.1.0",
"description": "Shared customer + admin auth client (VK ID/OTP/session, ed25519 admin verification, guards, interceptors) for marketplaces projects.", "description": "Standalone Angular authentication UI and client for marketplaces projects.",
"main": "dist/index.js", "module": "dist/fesm2022/marketplaces-auth.mjs",
"types": "dist/index.d.ts", "typings": "dist/types/marketplaces-auth.d.ts",
"files": ["dist"], "files": ["dist"],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "ng-packagr -p ng-package.json -c tsconfig.json",
"test": "echo \"no tests yet\" && exit 0" "test": "node --test test/*.test.mjs"
},
"dependencies": {
"qrcode": "^1.5.4",
"tslib": "^2.8.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": ">=22.0.0", "@angular/core": ">=22.0.0",
"@angular/common": ">=22.0.0", "@angular/common": ">=22.0.0",
"@angular/forms": ">=22.0.0",
"@angular/router": ">=22.0.0",
"rxjs": ">=7.8.0" "rxjs": ">=7.8.0"
}, },
"publishConfig": { "publishConfig": {

View File

@@ -1,7 +1,39 @@
import { InjectionToken } from '@angular/core'; import { EnvironmentProviders, InjectionToken, makeEnvironmentProviders } from '@angular/core';
export interface MarketplacesAuthConfig {
/** Central auth service URL. It is not the tenant API URL. */
apiUrl: string;
/** Override only for SSR/custom-domain integrations. Browser default is location.hostname. */
marketplaceDomain?: string | (() => string);
telegramBotUsername?: string;
credentialsPath?: string;
yandexStartPath?: string;
yandexSessionPath?: string;
pollIntervalMs?: number;
}
/** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */ /** Base URL for the auth backend, e.g. `https://api.example.com`. Provide from the consuming app's environment config. */
export const AUTH_API_URL = new InjectionToken<string>('@marketplaces/auth AUTH_API_URL'); export const AUTH_API_URL = new InjectionToken<string>('@marketplaces/auth AUTH_API_URL');
/** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */ /** Telegram bot username used to build QR/deep-link login URLs. Optional — falls back to a default if not provided. */
export const TELEGRAM_BOT_USERNAME = new InjectionToken<string>('@marketplaces/auth TELEGRAM_BOT_USERNAME'); export const TELEGRAM_BOT_USERNAME = new InjectionToken<string>('@marketplaces/auth TELEGRAM_BOT_USERNAME');
export const MARKETPLACES_AUTH_CONFIG = new InjectionToken<MarketplacesAuthConfig>('@marketplaces/auth config');
export function provideMarketplacesAuth(config: MarketplacesAuthConfig): EnvironmentProviders {
const normalized: MarketplacesAuthConfig = {
...config,
apiUrl: config.apiUrl.replace(/\/$/, ''),
credentialsPath: config.credentialsPath ?? '/auth/credentials/login',
yandexStartPath: config.yandexStartPath ?? '/auth/yandex/sessions',
yandexSessionPath: config.yandexSessionPath ?? '/auth/yandex/sessions',
pollIntervalMs: config.pollIntervalMs ?? 1500,
};
return makeEnvironmentProviders([
{ provide: MARKETPLACES_AUTH_CONFIG, useValue: normalized },
{ provide: AUTH_API_URL, useValue: normalized.apiUrl },
...(normalized.telegramBotUsername
? [{ provide: TELEGRAM_BOT_USERNAME, useValue: normalized.telegramBotUsername }]
: []),
]);
}

View File

@@ -5,7 +5,13 @@
// - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet) // - ed25519/ — future Ed25519 challenge/response admin auth (backend not shipped yet)
// Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config. // Provide AUTH_API_URL (and optionally TELEGRAM_BOT_USERNAME) from the consuming app's config.
export { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from './config'; export { AUTH_API_URL, TELEGRAM_BOT_USERNAME, MARKETPLACES_AUTH_CONFIG, provideMarketplacesAuth } from './config';
export type { MarketplacesAuthConfig } from './config';
export { MARKETPLACE_DOMAIN_HEADER, AuthMarketplaceContext, normalizeMarketplaceDomain } from './marketplace-context';
export { MarketplacesAuthComponent } from './ui/auth.component';
export { MARKETPLACES_AUTH_GATEWAY, HttpMarketplacesAuthGateway } from './ui/auth.gateway';
export type { MarketplacesAuthGateway } from './ui/auth.gateway';
export type { AuthMode, AuthMethod, CredentialLogin, AuthResult, ExternalAuthStart, AuthFailure } from './ui/auth.models';
// Telegram module // Telegram module
export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model'; export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model';

View File

@@ -0,0 +1,28 @@
import { HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { MARKETPLACES_AUTH_CONFIG } from './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 AuthMarketplaceContext {
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
domain(): string {
const configured = this.config.marketplaceDomain;
const domain = typeof configured === 'function'
? configured()
: configured ?? (typeof location === 'undefined' ? '' : location.hostname);
return normalizeMarketplaceDomain(domain);
}
headers(extra?: Record<string, string>): HttpHeaders {
const domain = this.domain();
if (!domain) throw new Error('Marketplace domain cannot be resolved');
return new HttpHeaders({ [MARKETPLACE_DOMAIN_HEADER]: domain, ...extra });
}
}

View File

@@ -124,6 +124,12 @@ export class AdminAuthService {
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated')); this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
} }
/** Accept a session/tokens returned by credentials or an external provider. */
acceptSession(session: AuthSession, token?: string, refreshToken?: string): void {
this.activateSession(session);
if (token && refreshToken) this.setAdminTokens(token, refreshToken);
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */ /** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null { getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY); return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);

View File

@@ -114,6 +114,11 @@ export class AuthService {
}); });
} }
/** Accept a session returned by credentials or an external provider. */
acceptSession(session: AuthSession): void {
this.activateSession(session);
}
private activateSession(session: AuthSession): void { private activateSession(session: AuthSession): void {
this.sessionSignal.set(session); this.sessionSignal.set(session);
this.statusSignal.set('authenticated'); this.statusSignal.set('authenticated');

View File

@@ -4,6 +4,7 @@ import { Observable, of, catchError, map } from 'rxjs';
import { AuthSession, WebSessionStart } from './models/session.model'; import { AuthSession, WebSessionStart } from './models/session.model';
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config'; import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config';
import { generateGuid } from '../util/guid.util'; import { generateGuid } from '../util/guid.util';
import { AuthMarketplaceContext } from '../marketplace-context';
const SESSION_MAX_AGE_SECONDS = 60 * 60; const SESSION_MAX_AGE_SECONDS = 60 * 60;
const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot'; const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot';
@@ -21,6 +22,7 @@ export class TelegramSessionApiService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly authApiUrl = inject(AUTH_API_URL); private readonly authApiUrl = inject(AUTH_API_URL);
private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true }); private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true });
private readonly marketplaceContext = inject(AuthMarketplaceContext);
createSession(): Observable<WebSessionStart> { createSession(): Observable<WebSessionStart> {
const webSessionID = generateGuid(); const webSessionID = generateGuid();
@@ -28,7 +30,7 @@ export class TelegramSessionApiService {
return this.http.post<Record<string, unknown>>( return this.http.post<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions`, `${this.authApiUrl}/users/sessions`,
{ webSessionID }, { webSessionID },
{ headers: { WebSessionID: webSessionID } } { headers: this.marketplaceContext.headers({ WebSessionID: webSessionID }) }
).pipe( ).pipe(
map(response => { map(response => {
const responseWebSessionID = this.extractSessionId(response, webSessionID); const responseWebSessionID = this.extractSessionId(response, webSessionID);
@@ -46,7 +48,8 @@ export class TelegramSessionApiService {
} }
return this.http.get<Record<string, unknown>>( return this.http.get<Record<string, unknown>>(
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}` `${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`,
{ headers: this.marketplaceContext.headers() }
).pipe( ).pipe(
map(response => this.normalizeWebSession(response, webSessionID)), map(response => this.normalizeWebSession(response, webSessionID)),
catchError(() => of(null)) catchError(() => of(null))
@@ -55,7 +58,7 @@ export class TelegramSessionApiService {
logout(webSessionID: string): Observable<unknown> { logout(webSessionID: string): Observable<unknown> {
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, { return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
headers: { WebSessionID: webSessionID } headers: this.marketplaceContext.headers({ WebSessionID: webSessionID })
}).pipe(catchError(() => of(null))); }).pipe(catchError(() => of(null)));
} }

View File

@@ -0,0 +1,124 @@
import { Component, DestroyRef, booleanAttribute, inject, input, output, signal } from '@angular/core';
import { FormField, form, required } from '@angular/forms/signals';
import * as QRCode from 'qrcode';
import { Subscription, switchMap, timer } from 'rxjs';
import { MARKETPLACES_AUTH_CONFIG } from '../config';
import { MARKETPLACES_AUTH_GATEWAY } from './auth.gateway';
import { AuthFailure, AuthMethod, AuthMode, AuthResult } from './auth.models';
@Component({
selector: 'mp-auth, marketplaces-auth',
standalone: true,
imports: [FormField],
template: `
<section class="mp-auth" aria-labelledby="mp-auth-title">
<h2 id="mp-auth-title">{{ title() }}</h2>
<div class="methods" role="tablist" aria-label="Способ входа">
@if (qr()) { <button type="button" [class.active]="method() === 'qr'" (click)="select('qr')">QR</button> }
@if (credentials()) { <button type="button" [class.active]="method() === 'credentials'" (click)="select('credentials')">Логин</button> }
@if (yandex()) { <button type="button" [class.active]="method() === 'yandex'" (click)="select('yandex')">Яндекс</button> }
</div>
@if (method() === 'credentials') {
<form (submit)="loginWithCredentials($event)">
<label>Логин<input autocomplete="username" [formField]="credentialsForm.login" /></label>
<label>Пароль<input type="password" autocomplete="current-password" [formField]="credentialsForm.password" /></label>
<button type="submit" [disabled]="busy() || credentialsForm().invalid()">Войти</button>
</form>
}
@if (method() === 'qr') {
@if (qrImage()) { <a [href]="externalUrl()!" target="_blank" rel="noopener"><img [src]="qrImage()!" alt="QR-код для входа" /></a> }
<button type="button" [disabled]="busy()" (click)="startQr()">{{ qrImage() ? 'Обновить QR' : 'Получить QR' }}</button>
}
@if (method() === 'yandex') { <button type="button" [disabled]="busy()" (click)="startYandex()">Войти через Яндекс</button> }
@if (busy()) { <p role="status">Ожидаем подтверждение…</p> }
@if (error()) { <p class="error" role="alert">{{ error()!.message }}</p> }
</section>
`,
styles: [`
:host{display:block}.mp-auth{font:inherit;color:inherit;display:grid;gap:1rem;max-width:25rem}
h2,p{margin:0}.methods{display:flex;gap:.5rem;flex-wrap:wrap}.methods button{background:transparent;color:inherit}
button,input{font:inherit;border:1px solid #c7c7c7;border-radius:.65rem;padding:.7rem .9rem}
button{cursor:pointer}.active,button[type=submit]{background:#111;color:#fff;border-color:#111}
button:disabled{opacity:.55;cursor:wait}form{display:grid;gap:.8rem}label{display:grid;gap:.35rem}
img{display:block;width:min(15rem,100%);height:auto;border-radius:.75rem}.error{color:#b42318}
`],
})
export class MarketplacesAuthComponent {
readonly qr = input(false, { transform: booleanAttribute });
readonly credentials = input(false, { transform: booleanAttribute });
readonly yandex = input(false, { transform: booleanAttribute });
readonly mode = input<AuthMode>('customer');
readonly title = input('Вход');
readonly authenticated = output<AuthResult>();
readonly authError = output<AuthFailure>();
readonly cancelled = output<void>();
readonly method = signal<AuthMethod | null>(null);
readonly busy = signal(false);
readonly error = signal<AuthFailure | null>(null);
readonly qrImage = signal<string | null>(null);
readonly externalUrl = signal<string | null>(null);
private readonly credentialsModel = signal({ login: '', password: '' });
readonly credentialsForm = form(this.credentialsModel, path => {
required(path.login, { message: 'Введите логин' });
required(path.password, { message: 'Введите пароль' });
});
private readonly gateway = inject(MARKETPLACES_AUTH_GATEWAY);
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
private poll?: Subscription;
constructor() {
inject(DestroyRef).onDestroy(() => this.poll?.unsubscribe());
queueMicrotask(() => this.select(this.qr() ? 'qr' : this.credentials() ? 'credentials' : this.yandex() ? 'yandex' : null));
}
select(method: AuthMethod | null): void { this.poll?.unsubscribe(); this.busy.set(false); this.error.set(null); this.method.set(method); }
startQr(): void {
this.begin();
this.gateway.startQr(this.mode()).subscribe({
next: attempt => void this.prepareQr(attempt.url, attempt.webSessionID).catch(cause => this.fail('qr', cause)),
error: cause => this.fail('qr', cause),
});
}
loginWithCredentials(event: Event): void {
event.preventDefault();
if (this.credentialsForm().invalid()) return;
this.begin();
this.gateway.loginWithCredentials(this.mode(), this.credentialsModel()).subscribe({
next: result => this.finish(result), error: cause => this.fail('credentials', cause),
});
}
startYandex(): void {
this.begin();
const returnUrl = typeof location === 'undefined' ? '' : location.href;
this.gateway.startYandex(this.mode(), returnUrl).subscribe({
next: attempt => {
const popup = typeof window === 'undefined' ? null : window.open(attempt.authorizationUrl, 'mp-yandex-auth', 'popup,width=520,height=720');
if (!popup) { this.fail('yandex', { method: 'yandex', code: 'popup_blocked', message: 'Браузер заблокировал окно Яндекса' }); return; }
this.pollForYandex(attempt.attemptId);
},
error: cause => this.fail('yandex', cause),
});
}
private pollForQr(attemptId: string): void {
this.poll?.unsubscribe();
this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkQr(this.mode(), attemptId)))
.subscribe({ next: session => { if (session?.active) this.finish({ method: 'qr', mode: this.mode(), session }); }, error: cause => this.fail('qr', cause) });
}
private async prepareQr(url: string, attemptId: string): Promise<void> {
this.externalUrl.set(url);
this.qrImage.set(await QRCode.toDataURL(url, { width: 320, margin: 1 }));
this.pollForQr(attemptId);
}
private pollForYandex(attemptId: string): void {
this.poll?.unsubscribe();
this.poll = timer(0, this.config.pollIntervalMs ?? 1500).pipe(switchMap(() => this.gateway.checkYandex(this.mode(), attemptId)))
.subscribe({ next: result => { if (result) this.finish(result); }, error: cause => this.fail('yandex', cause) });
}
private begin(): void { this.poll?.unsubscribe(); this.error.set(null); this.busy.set(true); }
private finish(result: AuthResult): void { this.poll?.unsubscribe(); this.busy.set(false); this.authenticated.emit(result); }
private fail(method: AuthMethod, cause: unknown): void {
this.poll?.unsubscribe(); this.busy.set(false);
const failure: AuthFailure = this.isFailure(cause) ? cause : { method, code: 'backend', message: 'Не удалось выполнить вход', cause };
this.error.set(failure); this.authError.emit(failure);
}
private isFailure(value: unknown): value is AuthFailure { return !!value && typeof value === 'object' && 'code' in value && 'message' in value; }
}

View File

@@ -0,0 +1,79 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, catchError, map, of, throwError } from 'rxjs';
import { MARKETPLACES_AUTH_CONFIG } from '../config';
import { AuthMarketplaceContext } from '../marketplace-context';
import { AdminAuthService } from '../telegram/admin-auth.service';
import { AuthService } from '../telegram/auth.service';
import { AuthSession, WebSessionStart } from '../telegram/models/session.model';
import { AuthFailure, AuthMode, AuthResult, CredentialLogin, ExternalAuthStart } from './auth.models';
export interface MarketplacesAuthGateway {
startQr(mode: AuthMode): Observable<WebSessionStart>;
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null>;
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult>;
startYandex(mode: AuthMode, returnUrl: string): Observable<ExternalAuthStart>;
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null>;
}
export const MARKETPLACES_AUTH_GATEWAY = new InjectionToken<MarketplacesAuthGateway>(
'@marketplaces/auth gateway',
{ providedIn: 'root', factory: () => inject(HttpMarketplacesAuthGateway) }
);
@Injectable({ providedIn: 'root' })
export class HttpMarketplacesAuthGateway implements MarketplacesAuthGateway {
private readonly http = inject(HttpClient);
private readonly config = inject(MARKETPLACES_AUTH_CONFIG);
private readonly context = inject(AuthMarketplaceContext);
private readonly customerAuth = inject(AuthService);
private readonly adminAuth = inject(AdminAuthService);
startQr(mode: AuthMode): Observable<WebSessionStart> {
return mode === 'admin' ? this.adminAuth.createWebSession() : this.customerAuth.createWebSession();
}
checkQr(mode: AuthMode, attemptId: string): Observable<AuthSession | null> {
return mode === 'admin' ? this.adminAuth.checkSessionOnce(attemptId) : this.customerAuth.checkSessionOnce(attemptId);
}
loginWithCredentials(mode: AuthMode, credentials: CredentialLogin): Observable<AuthResult> {
return this.http.post<AuthResult>(this.url(this.config.credentialsPath), { ...credentials, mode }, {
headers: this.context.headers(),
}).pipe(
map(result => this.accept(mode, { ...result, method: 'credentials', mode })),
catchError(error => throwError(() => this.failure('credentials', error)))
);
}
startYandex(mode: AuthMode, returnUrl: string): Observable<ExternalAuthStart> {
return this.http.post<ExternalAuthStart>(this.url(this.config.yandexStartPath), {
provider: 'yandex', mode, returnUrl,
}, { headers: this.context.headers() }).pipe(
catchError(error => throwError(() => this.failure('yandex', error)))
);
}
checkYandex(mode: AuthMode, attemptId: string): Observable<AuthResult | null> {
return this.http.get<AuthResult | null>(
`${this.url(this.config.yandexSessionPath)}/${encodeURIComponent(attemptId)}`,
{ headers: this.context.headers() }
).pipe(
map(result => result ? this.accept(mode, { ...result, method: 'yandex', mode }) : null),
catchError((error: HttpErrorResponse) => error.status === 404 || error.status === 202
? of(null)
: throwError(() => this.failure('yandex', error)))
);
}
private accept(mode: AuthMode, result: AuthResult): AuthResult {
if (mode === 'admin') this.adminAuth.acceptSession(result.session, result.accessToken, result.refreshToken);
else this.customerAuth.acceptSession(result.session);
return result;
}
private url(path = ''): string { return `${this.config.apiUrl}${path.startsWith('/') ? path : `/${path}`}`; }
private failure(method: 'credentials' | 'yandex', cause: unknown): AuthFailure {
const response = cause instanceof HttpErrorResponse ? cause : null;
return {
method,
code: response?.status === 401 ? 'invalid_credentials' : 'backend',
message: response?.error?.message || response?.message || 'Authentication failed',
cause,
};
}
}

View File

@@ -0,0 +1,19 @@
import { AuthSession } from '../telegram/models/session.model';
export type AuthMode = 'customer' | 'admin';
export type AuthMethod = 'qr' | 'credentials' | 'yandex';
export interface CredentialLogin { login: string; password: string; }
export interface AuthResult {
method: AuthMethod;
mode: AuthMode;
session: AuthSession;
accessToken?: string;
refreshToken?: string;
}
export interface ExternalAuthStart { attemptId: string; authorizationUrl: string; }
export interface AuthFailure {
method: AuthMethod;
code: 'configuration' | 'invalid_credentials' | 'backend' | 'popup_blocked' | 'expired';
message: string;
cause?: unknown;
}

View File

@@ -0,0 +1,9 @@
import '@angular/compiler';
import assert from 'node:assert/strict';
import test from 'node:test';
import { MARKETPLACE_DOMAIN_HEADER, normalizeMarketplaceDomain } from '../dist/fesm2022/marketplaces-auth.mjs';
test('sends the full normalized storefront host as project context', () => {
assert.equal(MARKETPLACE_DOMAIN_HEADER, 'X-Marketplace-Domain');
assert.equal(normalizeMarketplaceDomain(' Store1.Example.COM. '), 'store1.example.com');
});

View File

@@ -9,7 +9,12 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false "useDefineForClassFields": false,
"lib": ["ES2022", "DOM"]
}, },
"include": ["src"] "angularCompilerOptions": {
"compilationMode": "partial",
"strictTemplates": true
},
"include": ["src/**/*.ts"]
} }

View File

@@ -0,0 +1,6 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "dist",
"allowedNonPeerDependencies": ["qrcode"],
"lib": { "entryFile": "src/index.ts" }
}

View File

@@ -1,13 +1,17 @@
{ {
"name": "@marketplaces/payment", "name": "@marketplaces/payment",
"version": "0.1.0", "version": "0.1.0",
"description": "Shared payment/finance client (FX, pricing, checkout gateways) for marketplaces projects. Thin by design — payment business logic stays server-side per Phase 1/7 backend contracts.", "description": "Standalone Angular payment UI and central payment API client.",
"main": "dist/index.js", "module": "dist/fesm2022/marketplaces-payment.mjs",
"types": "dist/index.d.ts", "typings": "dist/types/marketplaces-payment.d.ts",
"files": ["dist"], "files": ["dist"],
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "ng-packagr -p ng-package.json -c tsconfig.json",
"test": "echo \"no tests yet\" && exit 0" "test": "node --test test/*.test.mjs"
},
"dependencies": {
"qrcode": "^1.5.4",
"tslib": "^2.8.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/core": ">=22.0.0", "@angular/core": ">=22.0.0",

View File

@@ -1,5 +1,7 @@
// @marketplaces/payment — public API barrel. export { MarketplacesPaymentComponent } from './payment.component';
// Scaffold only: code migrates here from src/app/core/finance and src/app/core/pricing export { MARKETPLACES_PAYMENT_CONFIG, provideMarketplacesPayment } from './payment.config';
// per ADR-0001 (marketplaces repo: docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md). export type { MarketplacesPaymentConfig } from './payment.config';
// Nothing is exported yet — the marketplaces repo still owns the live implementation until migration lands. export { MARKETPLACE_DOMAIN_HEADER, PaymentMarketplaceContext, normalizeMarketplaceDomain } from './marketplace-context';
export {}; 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';

View 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 });
}
}

View 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; }
}

View 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,
},
}]);
}

View 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);
}));
}
}

View 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;
}

View File

@@ -0,0 +1,9 @@
import '@angular/compiler';
import assert from 'node:assert/strict';
import test from 'node:test';
import { MARKETPLACE_DOMAIN_HEADER, normalizeMarketplaceDomain } from '../dist/fesm2022/marketplaces-payment.mjs';
test('sends the full normalized storefront host as project context', () => {
assert.equal(MARKETPLACE_DOMAIN_HEADER, 'X-Marketplace-Domain');
assert.equal(normalizeMarketplaceDomain(' Store1.Example.COM. '), 'store1.example.com');
});

View File

@@ -9,7 +9,12 @@
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"experimentalDecorators": true, "experimentalDecorators": true,
"useDefineForClassFields": false "useDefineForClassFields": false,
"lib": ["ES2022", "DOM"]
}, },
"include": ["src"] "angularCompilerOptions": {
"compilationMode": "partial",
"strictTemplates": true
},
"include": ["src/**/*.ts"]
} }