feat(packages): add standalone auth and payment
This commit is contained in:
6
.changeset/standalone-auth-payment.md
Normal file
6
.changeset/standalone-auth-payment.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@marketplaces/auth": minor
|
||||
"@marketplaces/payment": minor
|
||||
---
|
||||
|
||||
Add standalone central-API Angular auth and payment components with domain project context.
|
||||
26
README.md
26
README.md
@@ -2,8 +2,30 @@
|
||||
|
||||
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/payment` — `@marketplaces/payment`. Scaffold only, no implementation yet.
|
||||
- `packages/auth` — `@marketplaces/auth`. Standalone Angular UI + gateway for QR, credentials and Yandex auth.
|
||||
- `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`.
|
||||
|
||||
|
||||
47
docs/BACKEND-CONTRACT.md
Normal file
47
docs/BACKEND-CONTRACT.md
Normal 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
5193
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -11,7 +11,17 @@
|
||||
"release": "npm run build && changeset publish"
|
||||
},
|
||||
"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",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"ng-packagr": "22.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.8.0",
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
6
packages/auth/ng-package.json
Normal file
6
packages/auth/ng-package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "dist",
|
||||
"allowedNonPeerDependencies": ["qrcode"],
|
||||
"lib": { "entryFile": "src/index.ts" }
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
{
|
||||
"name": "@marketplaces/auth",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared customer + admin auth client (VK ID/OTP/session, ed25519 admin verification, guards, interceptors) for marketplaces projects.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"description": "Standalone Angular authentication UI and client for marketplaces projects.",
|
||||
"module": "dist/fesm2022/marketplaces-auth.mjs",
|
||||
"typings": "dist/types/marketplaces-auth.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "echo \"no tests yet\" && exit 0"
|
||||
"build": "ng-packagr -p ng-package.json -c tsconfig.json",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=22.0.0",
|
||||
"@angular/common": ">=22.0.0",
|
||||
"@angular/forms": ">=22.0.0",
|
||||
"@angular/router": ">=22.0.0",
|
||||
"rxjs": ">=7.8.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -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. */
|
||||
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. */
|
||||
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 }]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
// - 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.
|
||||
|
||||
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
|
||||
export { AuthSession, WebSessionStart, AuthStatus, AdminAuthStatus } from './telegram/models/session.model';
|
||||
|
||||
28
packages/auth/src/marketplace-context.ts
Normal file
28
packages/auth/src/marketplace-context.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,12 @@ export class AdminAuthService {
|
||||
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. */
|
||||
getAdminToken(): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
|
||||
@@ -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 {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable, of, catchError, map } from 'rxjs';
|
||||
import { AuthSession, WebSessionStart } from './models/session.model';
|
||||
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '../config';
|
||||
import { generateGuid } from '../util/guid.util';
|
||||
import { AuthMarketplaceContext } from '../marketplace-context';
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60;
|
||||
const DEFAULT_TELEGRAM_BOT_USERNAME = 'DexarSupport_bot';
|
||||
@@ -21,6 +22,7 @@ export class TelegramSessionApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authApiUrl = inject(AUTH_API_URL);
|
||||
private readonly telegramBotUsername = inject(TELEGRAM_BOT_USERNAME, { optional: true });
|
||||
private readonly marketplaceContext = inject(AuthMarketplaceContext);
|
||||
|
||||
createSession(): Observable<WebSessionStart> {
|
||||
const webSessionID = generateGuid();
|
||||
@@ -28,7 +30,7 @@ export class TelegramSessionApiService {
|
||||
return this.http.post<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions`,
|
||||
{ webSessionID },
|
||||
{ headers: { WebSessionID: webSessionID } }
|
||||
{ headers: this.marketplaceContext.headers({ WebSessionID: webSessionID }) }
|
||||
).pipe(
|
||||
map(response => {
|
||||
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
||||
@@ -46,7 +48,8 @@ export class TelegramSessionApiService {
|
||||
}
|
||||
|
||||
return this.http.get<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`,
|
||||
{ headers: this.marketplaceContext.headers() }
|
||||
).pipe(
|
||||
map(response => this.normalizeWebSession(response, webSessionID)),
|
||||
catchError(() => of(null))
|
||||
@@ -55,7 +58,7 @@ export class TelegramSessionApiService {
|
||||
|
||||
logout(webSessionID: string): Observable<unknown> {
|
||||
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
||||
headers: { WebSessionID: webSessionID }
|
||||
headers: this.marketplaceContext.headers({ WebSessionID: webSessionID })
|
||||
}).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
|
||||
124
packages/auth/src/ui/auth.component.ts
Normal file
124
packages/auth/src/ui/auth.component.ts
Normal 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; }
|
||||
}
|
||||
79
packages/auth/src/ui/auth.gateway.ts
Normal file
79
packages/auth/src/ui/auth.gateway.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/auth/src/ui/auth.models.ts
Normal file
19
packages/auth/src/ui/auth.models.ts
Normal 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;
|
||||
}
|
||||
9
packages/auth/test/domain-context.test.mjs
Normal file
9
packages/auth/test/domain-context.test.mjs
Normal 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');
|
||||
});
|
||||
@@ -9,7 +9,12 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false
|
||||
"useDefineForClassFields": false,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial",
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
6
packages/payment/ng-package.json
Normal file
6
packages/payment/ng-package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "dist",
|
||||
"allowedNonPeerDependencies": ["qrcode"],
|
||||
"lib": { "entryFile": "src/index.ts" }
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "@marketplaces/payment",
|
||||
"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.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"description": "Standalone Angular payment UI and central payment API client.",
|
||||
"module": "dist/fesm2022/marketplaces-payment.mjs",
|
||||
"typings": "dist/types/marketplaces-payment.d.ts",
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "echo \"no tests yet\" && exit 0"
|
||||
"build": "ng-packagr -p ng-package.json -c tsconfig.json",
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=22.0.0",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
9
packages/payment/test/domain-context.test.mjs
Normal file
9
packages/payment/test/domain-context.test.mjs
Normal 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');
|
||||
});
|
||||
@@ -9,7 +9,12 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false
|
||||
"useDefineForClassFields": false,
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial",
|
||||
"strictTemplates": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user