IMP-200: Add invoice events page (#379)

This commit is contained in:
Rinat Arsaev 2024-08-15 19:37:44 +05:00 committed by GitHub
parent 335100b3ce
commit c682179598
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 888 additions and 190 deletions

View File

@ -127,4 +127,9 @@ export class InvoicingService {
switchMap((c) => c.CancelChargeback(id, paymentId, chargebackId, params)),
);
}
// eslint-disable-next-line @typescript-eslint/naming-convention
GetEvents(...args: Parameters<payment_processing_InvoicingCodegenClient['GetEvents']>) {
return this.client$.pipe(switchMap((c) => c.GetEvents(...args)));
}
}

View File

@ -15,7 +15,7 @@ import { PartyManagementService } from '../../api/payment-processing';
@Injectable()
export class PartyStoreService {
party$: Observable<Party | Pick<Party, 'id'> | null> = this.route.params.pipe(
party$: Observable<Party | Partial<Party> | null> = this.route.params.pipe(
startWith(this.route.snapshot.params),
switchMap(({ partyID }) =>
partyID

View File

@ -1,35 +1,3 @@
<mat-toolbar *ngIf="party$ | async as party" style="height: 48px; padding: 0 24px">
<div style="display: flex; gap: 8px; align-items: center">
<!-- <div class="mat-body-2 mat-no-margin">-->
<!-- {{ party?.contact_info?.email }}-->
<!-- </div>-->
<v-tag
*ngIf="party?.blocking"
[color]="(party?.blocking | ngtUnionKey) === 'blocked' ? 'warn' : 'success'"
style="margin-top: 8px"
>{{ party?.blocking | ngtUnionKey | titlecase }}</v-tag
>
<v-tag
*ngIf="party?.suspension"
[color]="(party?.suspension | ngtUnionKey) === 'suspended' ? 'warn' : 'success'"
style="margin-top: 8px"
>{{ party?.suspension | ngtUnionKey | titlecase }}</v-tag
>
</div>
</mat-toolbar>
<mat-sidenav-container autosize>
<mat-sidenav-content style="overflow: unset"
><router-outlet></router-outlet
></mat-sidenav-content>
<mat-sidenav
[fixedTopGap]="64 + 48"
[opened]="!(sidenavInfoService.opened$ | async)"
fixedInViewport="true"
mode="side"
position="end"
style="background: transparent; border: none; padding: 24px 0 24px 0"
>
<v-nav [links]="links" type="secondary"></v-nav
></mat-sidenav>
</mat-sidenav-container>
<cc-sub-page-layout [links]="links" [tags]="tags$ | async"
><router-outlet></router-outlet
></cc-sub-page-layout>

View File

@ -1,5 +1,8 @@
import { Component } from '@angular/core';
import { Link } from '@vality/ng-core';
import { getUnionKey } from '@vality/ng-thrift';
import startCase from 'lodash-es/startCase';
import { map } from 'rxjs/operators';
import { AppAuthGuardService, Services } from '@cc/app/shared/services';
@ -48,6 +51,26 @@ export class PartyComponent {
},
].filter((item) => this.appAuthGuardService.userHasSomeServiceMethods(item.services));
party$ = this.partyStoreService.party$;
tags$ = this.party$.pipe(
map((party) => [
...(party?.blocking
? [
{
value: startCase(getUnionKey(party.blocking)),
color: getUnionKey(party.blocking) === 'blocked' ? 'warn' : 'success',
},
]
: []),
...(party?.suspension
? [
{
value: startCase(getUnionKey(party.suspension)),
color: getUnionKey(party.suspension) === 'suspended' ? 'warn' : 'success',
},
]
: []),
]),
);
constructor(
private appAuthGuardService: AppAuthGuardService,

View File

@ -8,6 +8,7 @@ import { NavComponent, TagModule } from '@vality/ng-core';
import { ThriftPipesModule } from '@vality/ng-thrift';
import { PageLayoutModule } from '../../shared';
import { SubPageLayoutComponent } from '../../shared/components/page-layout/components/sub-page-layout/sub-page-layout.component';
import { PartyRouting } from './party-routing.module';
import { PartyComponent } from './party.component';
@ -24,6 +25,7 @@ import { PartyComponent } from './party.component';
MatToolbar,
TagModule,
ThriftPipesModule,
SubPageLayoutComponent,
],
declarations: [PartyComponent],
})

View File

@ -0,0 +1,10 @@
<cc-page-layout [progress]="isLoading$ | async" title="Chargebacks">
<cc-page-layout-actions>
<button color="primary" mat-raised-button (click)="createChargeback()">Create</button>
</cc-page-layout-actions>
<cc-chargebacks
[chargebacks]="chargebacks$ | async"
[invoiceId]="(payment$ | async).invoice_id"
[paymentId]="(payment$ | async).id"
></cc-chargebacks>
</cc-page-layout>

View File

@ -0,0 +1,61 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { ActivatedRoute } from '@angular/router';
import { DialogResponseStatus, DialogService } from '@vality/ng-core';
import { merge, defer, Subject } from 'rxjs';
import { map, switchMap, shareReplay } from 'rxjs/operators';
import { InvoicingService } from '../../../../api/payment-processing';
import { PageLayoutModule } from '../../../../shared';
import { ChargebacksComponent } from '../../../../shared/components/chargebacks/chargebacks.component';
import { CreateChargebackDialogComponent } from '../../create-chargeback-dialog/create-chargeback-dialog.component';
import { PaymentDetailsService } from '../../payment-details.service';
@Component({
selector: 'cc-payment-chargebacks',
standalone: true,
imports: [CommonModule, PageLayoutModule, MatButton, ChargebacksComponent],
templateUrl: './payment-chargebacks.component.html',
styles: ``,
})
export class PaymentChargebacksComponent {
payment$ = this.paymentDetailsService.payment$;
isLoading$ = this.paymentDetailsService.isLoading$;
chargebacks$ = merge(
this.route.params,
defer(() => this.updateChargebacks$),
).pipe(
map(() => this.route.snapshot.params as Record<'invoiceID' | 'paymentID', string>),
switchMap(({ invoiceID, paymentID }) =>
this.invoicingService.GetPayment(invoiceID, paymentID),
),
map(({ chargebacks }) => chargebacks),
shareReplay({ refCount: true, bufferSize: 1 }),
);
private updateChargebacks$ = new Subject<void>();
constructor(
private route: ActivatedRoute,
private invoicingService: InvoicingService,
private dialogService: DialogService,
private dr: DestroyRef,
private paymentDetailsService: PaymentDetailsService,
) {}
createChargeback() {
this.dialogService
.open(
CreateChargebackDialogComponent,
this.route.snapshot.params as Record<'invoiceID' | 'paymentID', string>,
)
.afterClosed()
.pipe(takeUntilDestroyed(this.dr))
.subscribe(({ status }) => {
if (status === DialogResponseStatus.Success) {
this.updateChargebacks$.next();
}
});
}
}

View File

@ -0,0 +1,17 @@
<cc-page-layout
[progress]="isLoading$ | async"
id="{{
(payment$ | async) ? (payment$ | async)?.invoice_id + '.' + (payment$ | async)?.id : ''
}}"
title="Payment"
>
<mat-card>
<mat-card-content>
<cc-magista-thrift-viewer
[extensions]="extensions$ | async"
[value]="payment$ | async"
type="StatPayment"
></cc-magista-thrift-viewer>
</mat-card-content>
</mat-card>
</cc-page-layout>

View File

@ -0,0 +1,75 @@
import { AsyncPipe } from '@angular/common';
import { Component, Inject, LOCALE_ID } from '@angular/core';
import { MatCard, MatCardContent } from '@angular/material/card';
import { ThriftAstMetadata } from '@vality/domain-proto';
import { getImportValue, formatCurrency } from '@vality/ng-core';
import { isTypeWithAliases, getUnionValue } from '@vality/ng-thrift';
import { Observable, of } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { PageLayoutModule } from '../../../../shared';
import { MetadataViewExtension } from '../../../../shared/components/json-viewer';
import { MagistaThriftViewerComponent } from '../../../../shared/components/thrift-api-crud';
import { DomainMetadataViewExtensionsService } from '../../../../shared/components/thrift-api-crud/domain/domain-thrift-viewer/services/domain-metadata-view-extensions';
import { AmountCurrencyService } from '../../../../shared/services';
import { PaymentDetailsService } from '../../payment-details.service';
@Component({
selector: 'cc-payment-details',
standalone: true,
imports: [AsyncPipe, MagistaThriftViewerComponent, MatCard, MatCardContent, PageLayoutModule],
templateUrl: './payment-details.component.html',
styles: ``,
})
export class PaymentDetailsComponent {
payment$ = this.paymentDetailsService.payment$;
isLoading$ = this.paymentDetailsService.isLoading$;
metadata$ = getImportValue<ThriftAstMetadata[]>(import('@vality/magista-proto/metadata.json'));
extensions$: Observable<MetadataViewExtension[]> = this.payment$.pipe(
map((payment): MetadataViewExtension[] => [
this.domainMetadataViewExtensionsService.createShopExtension(payment.owner_id),
{
determinant: (d) => of(isTypeWithAliases(d, 'Amount', 'domain')),
extension: (_, amount: number) =>
this.amountCurrencyService.getCurrency(payment.currency_symbolic_code).pipe(
map((c) => ({
value: formatCurrency(
amount,
c.data.symbolic_code,
'long',
this._locale,
c.data.exponent,
),
})),
),
},
{
determinant: (d) =>
of(
isTypeWithAliases(d, 'InvoicePaymentStatus', 'domain') ||
isTypeWithAliases(d, 'InvoicePaymentFlow', 'magista'),
),
extension: (_, v) => of({ hidden: !Object.keys(getUnionValue(v)).length }),
},
{
determinant: (d) =>
of(
isTypeWithAliases(d?.trueParent, 'StatPayment', 'magista') &&
(d.field.name === 'make_recurrent' ||
d.field.name === 'currency_symbolic_code' ||
isTypeWithAliases(d, 'InvoiceID', 'domain') ||
isTypeWithAliases(d, 'InvoicePaymentID', 'domain')),
),
extension: () => of({ hidden: true }),
},
]),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(
private paymentDetailsService: PaymentDetailsService,
private domainMetadataViewExtensionsService: DomainMetadataViewExtensionsService,
private amountCurrencyService: AmountCurrencyService,
@Inject(LOCALE_ID) private _locale: string,
) {}
}

View File

@ -0,0 +1,6 @@
{{ text }}
@if (date) {
at {{ date | date: 'dd.MM.yyyy HH:mm:ss.SSS' }} ({{
date | humanizedDuration: { hasAgoEnding: true }
}})
}

View File

@ -0,0 +1,14 @@
import { DatePipe } from '@angular/common';
import { Component, Input } from '@angular/core';
import { HumanizedDurationPipe } from '@vality/ng-core';
@Component({
selector: 'cc-timeline-item-header',
standalone: true,
templateUrl: 'timeline-item-header.component.html',
imports: [HumanizedDurationPipe, DatePipe],
})
export class TimelineItemHeaderComponent {
@Input() date: string;
@Input() text: string;
}

View File

@ -0,0 +1,35 @@
<cc-page-layout [progress]="isLoading$ | async" title="Invoice Events">
<cc-timeline>
@for (e of events$ | async; track e) {
<cc-timeline-item>
<cc-timeline-item-badge [color]="e.color">
<mat-icon>{{ e.icon }}</mat-icon>
</cc-timeline-item-badge>
<cc-timeline-item-title>
<cc-timeline-item-header
[date]="e.date"
[text]="e.title"
></cc-timeline-item-header>
</cc-timeline-item-title>
@if (e.change) {
<cc-timeline-item-content>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
{{ e.expansionTitle }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<cc-domain-thrift-viewer
[namespace]="e.namespace"
[type]="e.type"
[value]="e.change"
></cc-domain-thrift-viewer>
</ng-template>
</mat-expansion-panel>
</cc-timeline-item-content>
}
</cc-timeline-item>
}
</cc-timeline>
</cc-page-layout>

View File

@ -0,0 +1,53 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIcon } from '@angular/material/icon';
import { HumanizedDurationPipe } from '@vality/ng-core';
import { ThriftPipesModule } from '@vality/ng-thrift';
import { switchMap } from 'rxjs';
import { shareReplay, map } from 'rxjs/operators';
import { TimelineModule } from '../../../../../components/timeline';
import { InvoicingService } from '../../../../api/payment-processing';
import { PageLayoutModule } from '../../../../shared';
import { DomainThriftViewerComponent } from '../../../../shared/components/thrift-api-crud';
import { PaymentDetailsService } from '../../payment-details.service';
import { TimelineItemHeaderComponent } from './components/timeline-item-header/timeline-item-header.component';
import { getInvoiceChangeInfo } from './utils/get-invoice-change-info';
@Component({
selector: 'cc-payment-events',
standalone: true,
imports: [
CommonModule,
PageLayoutModule,
TimelineModule,
MatIcon,
HumanizedDurationPipe,
ThriftPipesModule,
DomainThriftViewerComponent,
MatExpansionModule,
TimelineItemHeaderComponent,
],
templateUrl: './payment-events.component.html',
styles: ``,
})
export class PaymentEventsComponent {
payment$ = this.paymentDetailsService.payment$;
isLoading$ = this.paymentDetailsService.isLoading$;
events$ = this.payment$.pipe(
switchMap((payment) => this.invoicingService.GetEvents(payment.invoice_id, {})),
map((events) =>
events.flatMap((e) =>
(e.payload.invoice_changes || []).map((change) => getInvoiceChangeInfo(e, change)),
),
),
shareReplay({ refCount: true, bufferSize: 1 }),
);
constructor(
private paymentDetailsService: PaymentDetailsService,
private invoicingService: InvoicingService,
) {}
}

View File

@ -0,0 +1,416 @@
import { InvoiceChange, Event } from '@vality/domain-proto/internal/payment_processing';
import { getUnionKey, getUnionValue } from '@vality/ng-thrift';
import { upperFirst } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import startCase from 'lodash-es/startCase';
import { StatusColor } from '../../../../../styles';
function getKeyTitle(v: unknown) {
return String(v).replaceAll('_', ' ');
}
const STATUS_ICONS = {
warn: 'priority_high',
neutral: 'block',
success: 'check',
pending: 'pending',
};
export function getInvoiceChangeInfo(e: Event, change: InvoiceChange) {
switch (getUnionKey(change)) {
case 'invoice_created': {
return {
change: change.invoice_created.invoice,
type: `Invoice ${change.invoice_created.invoice.id}`,
namespace: 'domain',
title: 'Invoice created',
expansionTitle: 'Invoice',
date: e.created_at,
icon: 'request_quote',
};
}
case 'invoice_status_changed': {
const status = change.invoice_status_changed.status;
return {
change: isEmpty(getUnionValue(status)) ? null : status,
type: 'InvoiceStatus',
namespace: 'domain',
title: `Invoice ${getKeyTitle(getUnionKey(status))}`,
expansionTitle: 'Details',
date: e.created_at,
icon: {
unpaid: STATUS_ICONS.warn,
paid: STATUS_ICONS.success,
cancelled: STATUS_ICONS.neutral,
fulfilled: STATUS_ICONS.success,
}[getUnionKey(status)],
color: {
unpaid: StatusColor.Warn,
paid: StatusColor.Success,
cancelled: StatusColor.Neutral,
fulfilled: StatusColor.Success,
}[getUnionKey(status)],
};
}
case 'invoice_payment_change': {
const payload = change.invoice_payment_change.payload;
switch (getUnionKey(payload)) {
case 'invoice_payment_started': {
return {
change: payload.invoice_payment_started,
type: 'InvoicePaymentStarted',
namespace: 'payment_processing',
title: 'Invoice payment started',
expansionTitle: 'Payment',
date: e.created_at,
icon: 'start',
};
}
case 'invoice_payment_risk_score_changed': {
return {
title: `Risk score changed to ${
{ 9999: 'fatal', 100: 'high', 1: 'low' }[
payload.invoice_payment_risk_score_changed.risk_score
]
} (${payload.invoice_payment_risk_score_changed.risk_score})`,
date: e.created_at,
icon: 'emergency',
color: {
9999: StatusColor.Warn,
100: StatusColor.Pending,
1: StatusColor.Success,
}[payload.invoice_payment_risk_score_changed.risk_score],
};
}
case 'invoice_payment_route_changed': {
return {
change: payload.invoice_payment_route_changed,
type: 'InvoicePaymentRouteChanged',
namespace: 'payment_processing',
title: 'Invoice payment route changed',
expansionTitle: 'Route',
date: e.created_at,
icon: 'alt_route',
};
}
case 'invoice_payment_cash_flow_changed': {
return {
change: payload.invoice_payment_cash_flow_changed.cash_flow,
type: 'FinalCashFlow',
namespace: 'domain',
title: 'Invoice payment cash flow changed',
expansionTitle: 'Cash Flow',
date: e.created_at,
icon: 'account_tree',
};
}
case 'invoice_payment_status_changed': {
const statusChange = {
change: payload.invoice_payment_status_changed.status,
type: 'InvoicePaymentStatus',
namespace: 'domain',
title: `Invoice payment status changed to ${getKeyTitle(
getUnionKey(payload.invoice_payment_status_changed.status),
)}`,
expansionTitle: startCase(
getKeyTitle(getUnionKey(payload.invoice_payment_status_changed.status)),
),
date: e.created_at,
icon: 'priority_high',
};
switch (getUnionKey(payload.invoice_payment_status_changed.status)) {
case 'pending':
return {
...statusChange,
color: 'pending',
icon: 'pending',
change: null,
};
case 'processed':
return {
...statusChange,
color: 'pending',
icon: 'process_chart',
change: null,
};
case 'captured':
return { ...statusChange, color: 'success', icon: 'check' };
case 'cancelled':
return { ...statusChange, color: 'neutral', icon: 'block' };
case 'refunded':
return {
...statusChange,
color: 'success',
icon: 'undo',
change: null,
};
case 'failed':
return { ...statusChange, color: 'warn', icon: 'priority_high' };
case 'charged_back':
return {
...statusChange,
color: 'success',
icon: 'undo',
change: null,
};
}
return statusChange;
}
case 'invoice_payment_session_change': {
const sessionChange = {
change: payload.invoice_payment_session_change,
type: 'InvoicePaymentSessionChange',
namespace: 'payment_processing',
title: `Invoice payment ${getKeyTitle(
getUnionKey(payload.invoice_payment_session_change.payload),
)}`,
expansionTitle: startCase(
getKeyTitle(
getUnionKey(payload.invoice_payment_session_change.payload),
),
),
date: e.created_at,
icon: 'edit',
};
switch (getUnionKey(payload.invoice_payment_session_change.payload)) {
case 'session_started':
return {
...sessionChange,
icon: 'line_start',
};
case 'session_finished':
return {
...sessionChange,
color: {
succeeded: StatusColor.Success,
failed: StatusColor.Warn,
}[
getUnionKey(
payload.invoice_payment_session_change.payload
.session_finished.result,
)
],
icon: 'line_end',
};
case 'session_suspended':
return {
...sessionChange,
icon: 'pause',
color: 'pending',
};
case 'session_activated':
return {
...sessionChange,
icon: 'line_start',
};
case 'session_transaction_bound':
return {
...sessionChange,
icon: 'attach_file_add',
};
case 'session_proxy_state_changed':
return {
...sessionChange,
icon: 'horizontal_distribute',
};
case 'session_interaction_changed':
return {
...sessionChange,
icon: 'ads_click',
};
}
return sessionChange;
}
case 'invoice_payment_capture_started': {
return {
change: payload.invoice_payment_capture_started.data,
type: 'InvoicePaymentCaptureData',
namespace: 'payment_processing',
title: 'Invoice payment capture started',
expansionTitle: payload.invoice_payment_capture_started.data.reason,
date: e.created_at,
icon: 'capture',
};
}
case 'invoice_payment_chargeback_change': {
const p = payload.invoice_payment_chargeback_change.payload;
const chargebackChange = {
change: p,
type: 'InvoicePaymentChargebackChangePayload',
namespace: 'payment_processing',
title: `${upperFirst(getKeyTitle(getUnionKey(p)))} #${
payload.invoice_payment_chargeback_change.id
}`,
expansionTitle: 'Chargeback change',
date: e.created_at,
icon: 'edit',
};
switch (getUnionKey(p)) {
case 'invoice_payment_chargeback_created':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_created.chargeback,
type: 'InvoicePaymentChargeback',
namespace: 'domain',
expansionTitle: 'Chargeback',
icon: 'source_notes',
};
case 'invoice_payment_chargeback_status_changed':
return {
...chargebackChange,
change: null,
title: `${chargebackChange.title} to ${getKeyTitle(
getUnionKey(p.invoice_payment_chargeback_status_changed.status),
)}`,
icon: {
pending: STATUS_ICONS.pending,
accepted: STATUS_ICONS.success,
rejected: STATUS_ICONS.warn,
cancelled: STATUS_ICONS.neutral,
}[getUnionKey(p.invoice_payment_chargeback_status_changed.status)],
color: {
pending: StatusColor.Pending,
accepted: StatusColor.Success,
rejected: StatusColor.Warn,
cancelled: StatusColor.Neutral,
}[getUnionKey(p.invoice_payment_chargeback_status_changed.status)],
};
case 'invoice_payment_chargeback_cash_flow_changed':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_cash_flow_changed.cash_flow,
type: 'FinalCashFlow',
namespace: 'domain',
expansionTitle: 'Cash Flow',
icon: 'account_tree',
};
case 'invoice_payment_chargeback_body_changed':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_body_changed.body,
type: 'Cash',
namespace: 'domain',
expansionTitle: 'Cash',
icon: 'price_change',
};
case 'invoice_payment_chargeback_levy_changed':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_levy_changed.levy,
type: 'Cash',
namespace: 'domain',
expansionTitle: 'Cash',
icon: 'price_change',
};
case 'invoice_payment_chargeback_stage_changed':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_stage_changed.stage,
type: 'InvoicePaymentChargebackStage',
namespace: 'domain',
expansionTitle: 'Stage',
};
case 'invoice_payment_chargeback_target_status_changed':
return {
...chargebackChange,
change: null,
title: `${chargebackChange.title} to ${getKeyTitle(
getUnionKey(
p.invoice_payment_chargeback_target_status_changed.status,
),
)}`,
icon: {
pending: STATUS_ICONS.pending,
accepted: STATUS_ICONS.success,
rejected: STATUS_ICONS.warn,
cancelled: STATUS_ICONS.neutral,
}[
getUnionKey(
p.invoice_payment_chargeback_target_status_changed.status,
)
],
color: {
pending: StatusColor.Pending,
accepted: StatusColor.Success,
rejected: StatusColor.Warn,
cancelled: StatusColor.Neutral,
}[
getUnionKey(
p.invoice_payment_chargeback_target_status_changed.status,
)
],
};
case 'invoice_payment_chargeback_clock_update':
return {
...chargebackChange,
change: p.invoice_payment_chargeback_clock_update.clock,
type: 'AccounterClock',
namespace: 'domain',
expansionTitle: 'Clock',
icon: 'more_time',
};
}
return chargebackChange;
}
case 'invoice_payment_rollback_started': {
return {
change: payload.invoice_payment_rollback_started.reason,
type: 'OperationFailure',
namespace: 'domain',
title: 'Invoice payment rollback started',
expansionTitle: 'Reason',
date: e.created_at,
icon: 'start',
};
}
case 'invoice_payment_clock_update': {
return {
change: payload.invoice_payment_clock_update.clock,
type: 'AccounterClock',
namespace: 'domain',
title: 'Invoice payment clock updated',
expansionTitle: 'Clock',
date: e.created_at,
icon: 'more_time',
};
}
case 'invoice_payment_cash_changed': {
return {
change: payload.invoice_payment_cash_changed,
type: 'InvoicePaymentCashChanged',
namespace: 'payment_processing',
title: 'Invoice payment cash changed',
expansionTitle: 'Cash change',
date: e.created_at,
icon: 'price_change',
};
}
case 'invoice_payment_shop_limit_initiated': {
return {
title: 'Invoice payment shop limit initiated',
date: e.created_at,
icon: 'production_quantity_limits',
};
}
case 'invoice_payment_shop_limit_applied': {
return {
title: 'Invoice payment shop limit applied',
date: e.created_at,
icon: 'production_quantity_limits',
color: 'success',
};
}
}
}
}
return {
change: change,
type: 'InvoiceChange',
namespace: 'payment_processing',
title: 'Invoice changed',
expansionTitle: 'Invoice Change',
date: e.created_at,
icon: 'edit',
};
}

View File

@ -0,0 +1,7 @@
<cc-page-layout [progress]="isLoading$ | async" title="Refunds">
<cc-refunds-table
[invoiceID]="(payment$ | async).invoice_id"
[partyID]="(payment$ | async).owner_id"
[paymentID]="(payment$ | async).id"
></cc-refunds-table>
</cc-page-layout>

View File

@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { PageLayoutModule } from '../../../../shared';
import { PaymentDetailsService } from '../../payment-details.service';
import { RefundsTableModule } from '../../refunds-table';
@Component({
selector: 'cc-payment-refunds',
standalone: true,
imports: [CommonModule, PageLayoutModule, RefundsTableModule],
templateUrl: './payment-refunds.component.html',
styles: ``,
})
export class PaymentRefundsComponent {
payment$ = this.paymentDetailsService.payment$;
isLoading$ = this.paymentDetailsService.isLoading$;
constructor(private paymentDetailsService: PaymentDetailsService) {}
}

View File

@ -14,6 +14,41 @@ import { ROUTING_CONFIG } from './routing-config';
component: PaymentDetailsComponent,
canActivate: [AppAuthGuardService],
data: ROUTING_CONFIG,
children: [
{
path: 'details',
loadComponent: () =>
import('./components/payment-details/payment-details.component').then(
(m) => m.PaymentDetailsComponent,
),
},
{
path: 'chargebacks',
loadComponent: () =>
import(
'./components/payment-chargebacks/payment-chargebacks.component'
).then((m) => m.PaymentChargebacksComponent),
},
{
path: 'refunds',
loadComponent: () =>
import('./components/payment-refunds/payment-refunds.component').then(
(m) => m.PaymentRefundsComponent,
),
},
{
path: 'events',
loadComponent: () =>
import('./components/payment-events/payment-events.component').then(
(m) => m.PaymentEventsComponent,
),
},
{
path: '',
redirectTo: 'details',
pathMatch: 'full',
},
],
},
]),
],

View File

@ -1,41 +1,15 @@
<cc-page-layout
[progress]="isLoading$ | async"
<cc-sub-page-layout
[links]="[
{ label: 'Payment', url: 'details' },
{ label: 'Events', url: 'events' },
{ label: 'Refunds', url: 'refunds' },
{ label: 'Chargebacks', url: 'chargebacks' }
]"
[tags]="tags$ | async"
id="{{
(payment$ | async) ? (payment$ | async)?.invoice_id + '.' + (payment$ | async)?.id : ''
}}"
title="Payment"
>
<ng-container *ngIf="payment$ | async as payment">
<mat-card>
<mat-card-content>
<cc-magista-thrift-viewer
[extensions]="extensions$ | async"
[value]="payment"
type="StatPayment"
></cc-magista-thrift-viewer>
</mat-card-content>
</mat-card>
<h2 class="mat-h1 mat-no-margin">Refunds</h2>
<cc-refunds-table
[invoiceID]="payment.invoice_id"
[partyID]="payment.owner_id"
[paymentID]="payment.id"
></cc-refunds-table>
<div style="display: flex; place-content: stretch space-between">
<h2 class="mat-h1 mat-no-margin">Chargebacks</h2>
<v-actions>
<button color="primary" mat-button (click)="createChargeback()">
Create chargeback
</button>
</v-actions>
</div>
<cc-chargebacks
[chargebacks]="chargebacks$ | async"
[invoiceId]="payment.invoice_id"
[paymentId]="payment.id"
></cc-chargebacks>
</ng-container>
</cc-page-layout>
<router-outlet></router-outlet>
</cc-sub-page-layout>

View File

@ -1,27 +1,10 @@
import { ChangeDetectionStrategy, Component, DestroyRef, Inject, LOCALE_ID } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { ThriftAstMetadata } from '@vality/domain-proto';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { InvoicePaymentStatus, InvoicePaymentFlow } from '@vality/domain-proto/internal/domain';
import {
DialogService,
DialogResponseStatus,
getImportValue,
formatCurrency,
Color,
} from '@vality/ng-core';
import { getUnionKey, getUnionValue, isTypeWithAliases } from '@vality/ng-thrift';
import { Color } from '@vality/ng-core';
import { getUnionKey } from '@vality/ng-thrift';
import startCase from 'lodash-es/startCase';
import { Subject, merge, defer, Observable, of } from 'rxjs';
import { shareReplay, switchMap, map } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { InvoicingService } from '@cc/app/api/payment-processing';
import { MetadataViewExtension } from '../../shared/components/json-viewer';
import { DomainMetadataViewExtensionsService } from '../../shared/components/thrift-api-crud/domain/domain-thrift-viewer/services/domain-metadata-view-extensions';
import { AmountCurrencyService } from '../../shared/services';
import { CreateChargebackDialogComponent } from './create-chargeback-dialog/create-chargeback-dialog.component';
import { PaymentDetailsService } from './payment-details.service';
@Component({
@ -32,61 +15,11 @@ import { PaymentDetailsService } from './payment-details.service';
export class PaymentDetailsComponent {
payment$ = this.paymentDetailsService.payment$;
isLoading$ = this.paymentDetailsService.isLoading$;
chargebacks$ = merge(
this.route.params,
defer(() => this.updateChargebacks$),
).pipe(
map(() => this.route.snapshot.params as Record<'invoiceID' | 'paymentID', string>),
switchMap(({ invoiceID, paymentID }) =>
this.invoicingService.GetPayment(invoiceID, paymentID),
),
map(({ chargebacks }) => chargebacks),
shareReplay({ refCount: true, bufferSize: 1 }),
);
metadata$ = getImportValue<ThriftAstMetadata[]>(import('@vality/magista-proto/metadata.json'));
extensions$: Observable<MetadataViewExtension[]> = this.payment$.pipe(
map((payment): MetadataViewExtension[] => [
this.domainMetadataViewExtensionsService.createShopExtension(payment.owner_id),
{
determinant: (d) => of(isTypeWithAliases(d, 'Amount', 'domain')),
extension: (_, amount: number) =>
this.amountCurrencyService.getCurrency(payment.currency_symbolic_code).pipe(
map((c) => ({
value: formatCurrency(
amount,
c.data.symbolic_code,
'long',
this._locale,
c.data.exponent,
),
})),
),
},
{
determinant: (d) =>
of(
isTypeWithAliases(d, 'InvoicePaymentStatus', 'domain') ||
isTypeWithAliases(d, 'InvoicePaymentFlow', 'magista'),
),
extension: (_, v) => of({ hidden: !Object.keys(getUnionValue(v)).length }),
},
{
determinant: (d) =>
of(
isTypeWithAliases(d?.trueParent, 'StatPayment', 'magista') &&
(d.field.name === 'make_recurrent' ||
d.field.name === 'currency_symbolic_code' ||
isTypeWithAliases(d, 'InvoiceID', 'domain') ||
isTypeWithAliases(d, 'InvoicePaymentID', 'domain')),
),
extension: () => of({ hidden: true }),
},
]),
shareReplay({ refCount: true, bufferSize: 1 }),
);
tags$ = this.payment$.pipe(
map((payment) => [
{
value: payment.currency_symbolic_code,
},
{
value: startCase(getUnionKey(payment.status)),
color: (
@ -101,50 +34,29 @@ export class PaymentDetailsComponent {
} as Record<keyof InvoicePaymentStatus, Color>
)[getUnionKey(payment.status)],
},
{
value: startCase(getUnionKey(payment.flow)),
color: (
{
instant: 'success',
hold: 'pending',
} as Record<keyof InvoicePaymentFlow, Color>
)[getUnionKey(payment.flow)],
},
{
value: payment.make_recurrent ? 'Recurrent' : 'Not Recurrent',
color: payment.make_recurrent ? 'success' : 'neutral',
},
{
value: payment.currency_symbolic_code,
},
...(getUnionKey(payment.flow) === 'hold'
? [
{
value: startCase(getUnionKey(payment.flow)),
color: (
{
instant: 'success',
hold: 'pending',
} as Record<keyof InvoicePaymentFlow, Color>
)[getUnionKey(payment.flow)],
},
]
: []),
...(payment.make_recurrent
? [
{
value: payment.make_recurrent ? 'Recurrent' : 'Not Recurrent',
color: payment.make_recurrent ? 'pending' : 'neutral',
},
]
: []),
]),
);
private updateChargebacks$ = new Subject<void>();
constructor(
private paymentDetailsService: PaymentDetailsService,
private route: ActivatedRoute,
private invoicingService: InvoicingService,
private dialogService: DialogService,
private destroyRef: DestroyRef,
private domainMetadataViewExtensionsService: DomainMetadataViewExtensionsService,
private amountCurrencyService: AmountCurrencyService,
@Inject(LOCALE_ID) private _locale: string,
) {}
createChargeback() {
this.dialogService
.open(
CreateChargebackDialogComponent,
this.route.snapshot.params as Record<'invoiceID' | 'paymentID', string>,
)
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ status }) => {
if (status === DialogResponseStatus.Success) {
this.updateChargebacks$.next();
}
});
}
constructor(private paymentDetailsService: PaymentDetailsService) {}
}

View File

@ -5,9 +5,10 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialogModule } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { RouterOutlet } from '@angular/router';
import { ActionsModule, DialogModule } from '@vality/ng-core';
import { StatusModule, PageLayoutModule } from '@cc/app/shared/components';
import { StatusModule, PageLayoutModule, SubPageLayoutComponent } from '@cc/app/shared/components';
import { DetailsItemModule } from '@cc/components/details-item';
import { HeadlineModule } from '@cc/components/headline';
@ -43,6 +44,8 @@ import { RefundsTableModule } from './refunds-table';
JsonViewerModule,
RefundsTableModule,
MagistaThriftViewerComponent,
SubPageLayoutComponent,
RouterOutlet,
],
declarations: [PaymentDetailsComponent, CreateChargebackDialogComponent],
})

View File

@ -0,0 +1,26 @@
<mat-toolbar *ngIf="tags()?.length" style="height: auto; min-height: 48px; padding: 0 24px">
<v-actions style="width: 100%">
<div class="mat-body-strong" style="margin: auto auto auto 0">
{{ title() }} {{ id() ? '#' + id() : '' }}
</div>
<div style="display: flex; gap: 8px; align-items: center">
@for (tag of tags(); track tag) {
<v-tag [color]="tag.color" style="margin-top: 8px">{{ tag.value }}</v-tag>
}
</div>
</v-actions>
</mat-toolbar>
<mat-sidenav-container autosize>
<mat-sidenav-content style="overflow: unset"><ng-content></ng-content></mat-sidenav-content>
<mat-sidenav
[fixedTopGap]="64 + 48"
[opened]="!(sidenavInfoService.opened$ | async)"
fixedInViewport="true"
mode="side"
position="end"
style="background: transparent; border: none; padding: 24px 0 24px 0"
>
<v-nav [links]="links()" type="secondary"></v-nav
></mat-sidenav>
</mat-sidenav-container>

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common';
import { Component, input } from '@angular/core';
import { MatSidenav, MatSidenavContent, MatSidenavContainer } from '@angular/material/sidenav';
import { MatToolbar } from '@angular/material/toolbar';
import { NavComponent, TagModule, Color, Link, ActionsModule } from '@vality/ng-core';
import { SidenavInfoService } from '../../../sidenav-info';
@Component({
selector: 'cc-sub-page-layout',
standalone: true,
imports: [
CommonModule,
NavComponent,
MatSidenav,
MatSidenavContent,
MatToolbar,
TagModule,
MatSidenavContainer,
ActionsModule,
],
templateUrl: './sub-page-layout.component.html',
styles: ``,
})
export class SubPageLayoutComponent {
title = input<string>();
id = input<string>();
links = input<Link[]>([]);
tags = input<{ value: string; color: Color }[] | null>([]);
constructor(protected sidenavInfoService: SidenavInfoService) {}
}

View File

@ -1 +1,2 @@
export * from './page-layout.module';
export * from './components/sub-page-layout/sub-page-layout.component';

View File

@ -10,6 +10,7 @@ import { ActionsModule, TagModule } from '@vality/ng-core';
import { ThriftPipesModule } from '@vality/ng-thrift';
import { PageLayoutActionsComponent } from './components/page-layout-actions/page-layout-actions.component';
import { SubPageLayoutComponent } from './components/sub-page-layout/sub-page-layout.component';
import { PageLayoutComponent } from './page-layout.component';
@NgModule({
@ -24,6 +25,7 @@ import { PageLayoutComponent } from './page-layout.component';
MatToolbar,
TagModule,
ThriftPipesModule,
SubPageLayoutComponent,
],
declarations: [PageLayoutComponent, PageLayoutActionsComponent],
exports: [PageLayoutComponent, PageLayoutActionsComponent],

View File

@ -3,8 +3,8 @@
[extensions]="extensions$ | async"
[kind]="kind"
[metadata]="metadata$ | async"
[namespace]="namespace"
[progress]="progress"
[type]="type"
[value]="value"
namespace="domain"
></cc-thrift-viewer>

View File

@ -20,6 +20,7 @@ export class DomainThriftViewerComponent<T> {
@Input() compared?: T;
@Input() type: ValueType;
@Input({ transform: booleanAttribute }) progress: boolean = false;
@Input() namespace = 'domain';
// @Input() extensions?: MetadataViewExtension[];
metadata$ = getImportValue<ThriftAstMetadata[]>(import('@vality/domain-proto/metadata.json'));