mirror of
https://github.com/valitydev/control-center.git
synced 2024-11-06 02:25:17 +00:00
IMP-241: Create deposits by CSV (#366)
This commit is contained in:
parent
26832f6087
commit
e4f044193c
@ -20,12 +20,14 @@ import {
|
||||
} from '@vality/ng-core';
|
||||
import { ThriftPipesModule } from '@vality/ng-thrift';
|
||||
|
||||
import { MagistaThriftFormComponent } from '@cc/app/shared/components/thrift-api-crud';
|
||||
|
||||
import { UploadCsvComponent } from '../../../components/upload-csv';
|
||||
import { PageLayoutModule, ShopFieldModule } from '../../shared';
|
||||
import { MerchantFieldModule } from '../../shared/components/merchant-field';
|
||||
import { ThriftFormModule } from '../../shared/components/metadata-form';
|
||||
import { DomainThriftFormComponent } from '../../shared/components/thrift-api-crud/domain/domain-thrift-form';
|
||||
import {
|
||||
MagistaThriftFormComponent,
|
||||
DomainThriftFormComponent,
|
||||
} from '../../shared/components/thrift-api-crud';
|
||||
|
||||
import { ChargebacksRoutingModule } from './chargebacks-routing.module';
|
||||
import { ChargebacksComponent } from './chargebacks.component';
|
||||
@ -64,6 +66,7 @@ import { CreateChargebacksByFileDialogComponent } from './components/create-char
|
||||
MatExpansionModule,
|
||||
MatInputModule,
|
||||
MatCheckboxModule,
|
||||
UploadCsvComponent,
|
||||
],
|
||||
})
|
||||
export class ChargebacksModule {}
|
||||
|
@ -1,36 +1,9 @@
|
||||
<v-dialog [progress]="progress$ | async" title="Create chargebacks">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px">
|
||||
<mat-checkbox [formControl]="hasHeaderControl">CSV with header</mat-checkbox>
|
||||
|
||||
<v-file-upload [extensions]="['csv']" (upload)="loadFile($event)"></v-file-upload>
|
||||
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>Default format</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="mat-body-1">
|
||||
<code style="word-wrap: break-word; word-break: break-word">{{
|
||||
defaultFormat
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="mat-body-1">
|
||||
Categories: fraud, dispute, authorisation, processing_error
|
||||
</div>
|
||||
<div class="mat-body-1">Starting with "body" optional</div>
|
||||
<div class="mat-body-1">Separator dot comma (;)</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
|
||||
<v-table
|
||||
[(rowSelected)]="selectedChargebacks"
|
||||
[columns]="columns"
|
||||
[data]="chargebacks$ | async"
|
||||
noActions
|
||||
rowSelectable
|
||||
></v-table>
|
||||
</div>
|
||||
|
||||
<cc-upload-csv
|
||||
[formatDescription]="['reason.category: fraud, dispute, authorisation, processing_error']"
|
||||
[props]="props"
|
||||
(selectedChange)="selected = $event"
|
||||
></cc-upload-csv>
|
||||
<v-dialog-actions>
|
||||
<button
|
||||
*ngIf="successfullyChargebacks?.length"
|
||||
@ -41,7 +14,7 @@
|
||||
Close and find {{ successfullyChargebacks.length }} successful chargebacks
|
||||
</button>
|
||||
<button
|
||||
[disabled]="!selectedChargebacks?.length || !!(progress$ | async)"
|
||||
[disabled]="!selected?.length || !!(progress$ | async)"
|
||||
color="primary"
|
||||
mat-button
|
||||
(click)="create()"
|
||||
|
@ -1,162 +1,48 @@
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { Component, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { InvoicePaymentChargeback } from '@vality/domain-proto/domain';
|
||||
import { InvoicePaymentChargebackParams } from '@vality/domain-proto/payment_processing';
|
||||
import {
|
||||
DialogSuperclass,
|
||||
NotifyLogService,
|
||||
loadFileContent,
|
||||
DEFAULT_DIALOG_CONFIG,
|
||||
forkJoinToResult,
|
||||
Column,
|
||||
} from '@vality/ng-core';
|
||||
import { getUnionKey } from '@vality/ng-thrift';
|
||||
import startCase from 'lodash-es/startCase';
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { switchMap, map, shareReplay, tap, startWith } from 'rxjs/operators';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { InvoicingService } from '@cc/app/api/payment-processing';
|
||||
import { parseCsv, unifyCsvItems } from '@cc/utils';
|
||||
|
||||
import { AmountCurrencyService } from '../../../../shared/services';
|
||||
|
||||
import { CSV_CHARGEBACK_PROPS } from './types/csv-chargeback-props';
|
||||
import { csvChargebacksToInvoicePaymentChargebackParams } from './utils/csv-chargebacks-to-invoice-payment-chargeback-params';
|
||||
|
||||
interface ChargebackParams {
|
||||
invoiceId: string;
|
||||
paymentId: string;
|
||||
params: InvoicePaymentChargebackParams;
|
||||
}
|
||||
import { CSV_CHARGEBACK_PROPS, CsvChargeback } from './types/csv-chargeback';
|
||||
import { getCreateChargebackArgs } from './utils/get-create-chargeback-args';
|
||||
|
||||
@Component({
|
||||
selector: 'cc-create-chargebacks-by-file-dialog',
|
||||
templateUrl: './create-chargebacks-by-file-dialog.component.html',
|
||||
})
|
||||
export class CreateChargebacksByFileDialogComponent
|
||||
extends DialogSuperclass<
|
||||
CreateChargebacksByFileDialogComponent,
|
||||
void,
|
||||
InvoicePaymentChargeback[]
|
||||
>
|
||||
implements OnInit
|
||||
{
|
||||
export class CreateChargebacksByFileDialogComponent extends DialogSuperclass<
|
||||
CreateChargebacksByFileDialogComponent,
|
||||
void,
|
||||
InvoicePaymentChargeback[]
|
||||
> {
|
||||
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
|
||||
|
||||
hasHeaderControl = new FormControl(true);
|
||||
progress$ = new BehaviorSubject(0);
|
||||
upload$ = new BehaviorSubject<File | null>(null);
|
||||
columns: Column<ChargebackParams>[] = [
|
||||
{ field: 'invoiceId' },
|
||||
{ field: 'paymentId' },
|
||||
// { field: 'params.id' },
|
||||
{
|
||||
field: 'params.reason',
|
||||
formatter: ({ params }) => startCase(getUnionKey(params.reason.category)),
|
||||
description: ({ params }) => params.reason.code,
|
||||
},
|
||||
{
|
||||
field: 'levy',
|
||||
type: 'currency',
|
||||
formatter: ({ params }) =>
|
||||
this.amountCurrencyService.toMajor(
|
||||
params.levy.amount,
|
||||
params.levy.currency.symbolic_code,
|
||||
),
|
||||
typeParameters: {
|
||||
currencyCode: ({ params }) => params.levy.currency.symbolic_code,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'params.body',
|
||||
type: 'currency',
|
||||
formatter: ({ params }) =>
|
||||
this.amountCurrencyService.toMajor(
|
||||
params.body?.amount,
|
||||
params.body?.currency?.symbolic_code,
|
||||
),
|
||||
typeParameters: {
|
||||
currencyCode: ({ params }) => params.body?.currency?.symbolic_code,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'params.transaction_info.id',
|
||||
header: 'Transaction id',
|
||||
},
|
||||
{
|
||||
field: 'params.transaction_info.timestamp',
|
||||
header: 'Transaction timestamp',
|
||||
type: 'datetime',
|
||||
},
|
||||
{
|
||||
field: 'params.external_id',
|
||||
},
|
||||
{
|
||||
field: 'params.occurred_at',
|
||||
type: 'datetime',
|
||||
},
|
||||
];
|
||||
defaultFormat = CSV_CHARGEBACK_PROPS.join(';');
|
||||
selectedChargebacks: ChargebackParams[] = [];
|
||||
chargebacks$ = combineLatest([
|
||||
this.upload$,
|
||||
this.hasHeaderControl.valueChanges.pipe(startWith(null)),
|
||||
]).pipe(
|
||||
switchMap(([file]) => loadFileContent(file)),
|
||||
map((content) =>
|
||||
parseCsv(content, { header: this.hasHeaderControl.value || false, delimiter: ';' }),
|
||||
),
|
||||
tap((d) => {
|
||||
if (!d.errors.length) {
|
||||
return;
|
||||
}
|
||||
if (d.errors.length === 1) {
|
||||
this.log.error(d.errors[0]);
|
||||
}
|
||||
this.log.error(new Error(d.errors.map((e) => e.message).join('. ')));
|
||||
}),
|
||||
map((d) => {
|
||||
const chargebacks = unifyCsvItems(d?.data, CSV_CHARGEBACK_PROPS);
|
||||
if (chargebacks[0].invoice_id) {
|
||||
return chargebacks;
|
||||
}
|
||||
this.log.error(
|
||||
'Perhaps you incorrectly checked the checkbox to have or not a header (the first element does not have at least an invoice ID)',
|
||||
);
|
||||
return [];
|
||||
}),
|
||||
map((chargebacks) =>
|
||||
chargebacks.map((c) => ({
|
||||
invoiceId: c.invoice_id as string,
|
||||
paymentId: c.payment_id as string,
|
||||
params: csvChargebacksToInvoicePaymentChargebackParams(c),
|
||||
})),
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
selected: CsvChargeback[] = [];
|
||||
successfullyChargebacks: InvoicePaymentChargeback[] = [];
|
||||
props = CSV_CHARGEBACK_PROPS;
|
||||
|
||||
constructor(
|
||||
private invoicingService: InvoicingService,
|
||||
private log: NotifyLogService,
|
||||
private amountCurrencyService: AmountCurrencyService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.chargebacks$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((c) => {
|
||||
this.selectedChargebacks = c || [];
|
||||
});
|
||||
}
|
||||
|
||||
create() {
|
||||
const selected = this.selectedChargebacks;
|
||||
const selected = this.selected;
|
||||
forkJoinToResult(
|
||||
selected.map((c) =>
|
||||
this.invoicingService.CreateChargeback(c.invoiceId, c.paymentId, c.params),
|
||||
this.invoicingService.CreateChargeback(...getCreateChargebackArgs(c)),
|
||||
),
|
||||
this.progress$,
|
||||
)
|
||||
@ -172,9 +58,7 @@ export class CreateChargebacksByFileDialogComponent
|
||||
chargebacksWithError.map((c) => c.error),
|
||||
`Creating ${chargebacksWithError.length} chargebacks ended in an error. They were re-selected in the table.`,
|
||||
);
|
||||
this.selectedChargebacks = chargebacksWithError.map(
|
||||
(c) => selected[c.index],
|
||||
);
|
||||
this.selected = chargebacksWithError.map((c) => selected[c.index]);
|
||||
} else {
|
||||
this.log.successOperation('create', 'chargebacks');
|
||||
this.closeWithSuccess();
|
||||
@ -184,10 +68,6 @@ export class CreateChargebacksByFileDialogComponent
|
||||
});
|
||||
}
|
||||
|
||||
async loadFile(file: File) {
|
||||
this.upload$.next(file);
|
||||
}
|
||||
|
||||
override closeWithSuccess() {
|
||||
super.closeWithSuccess(this.successfullyChargebacks);
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
export const CSV_CHARGEBACK_PROPS = [
|
||||
'invoice_id',
|
||||
'payment_id',
|
||||
|
||||
'reason.category',
|
||||
'reason.code',
|
||||
|
||||
'levy.amount',
|
||||
'levy.currency.symbolic_code',
|
||||
|
||||
// Optional
|
||||
'body.amount',
|
||||
'body.currency.symbolic_code',
|
||||
|
||||
'external_id',
|
||||
'occurred_at',
|
||||
|
||||
'context.type',
|
||||
'context.data',
|
||||
|
||||
'transaction_info.id',
|
||||
'transaction_info.timestamp',
|
||||
'transaction_info.extra',
|
||||
] as const;
|
@ -1,5 +1,35 @@
|
||||
import { CsvItem } from '@cc/utils';
|
||||
import { DeepReadonly } from 'utility-types';
|
||||
|
||||
import { CSV_CHARGEBACK_PROPS } from './csv-chargeback-props';
|
||||
import { CsvProps, CsvObject } from '../../../../../../components/upload-csv';
|
||||
|
||||
export type CsvChargeback = CsvItem<(typeof CSV_CHARGEBACK_PROPS)[number]>;
|
||||
export const CSV_CHARGEBACK_PROPS = {
|
||||
required: [
|
||||
'invoice_id',
|
||||
'payment_id',
|
||||
|
||||
'reason.category',
|
||||
'reason.code',
|
||||
|
||||
'levy.amount',
|
||||
'levy.currency.symbolic_code',
|
||||
],
|
||||
optional: [
|
||||
'body.amount',
|
||||
'body.currency.symbolic_code',
|
||||
|
||||
'external_id',
|
||||
'occurred_at',
|
||||
|
||||
'context.type',
|
||||
'context.data',
|
||||
|
||||
'transaction_info.id',
|
||||
'transaction_info.timestamp',
|
||||
'transaction_info.extra',
|
||||
],
|
||||
} as const satisfies DeepReadonly<CsvProps>;
|
||||
|
||||
export type CsvChargeback = CsvObject<
|
||||
(typeof CSV_CHARGEBACK_PROPS)['required'][number],
|
||||
(typeof CSV_CHARGEBACK_PROPS)['optional'][number]
|
||||
>;
|
||||
|
@ -1,57 +0,0 @@
|
||||
import { InvoicePaymentChargebackParams } from '@vality/domain-proto/payment_processing';
|
||||
import { clean } from '@vality/ng-core';
|
||||
import short from 'short-uuid';
|
||||
|
||||
import { CsvChargeback } from '../types/csv-chargeback';
|
||||
|
||||
export function csvChargebacksToInvoicePaymentChargebackParams(
|
||||
c: CsvChargeback,
|
||||
): InvoicePaymentChargebackParams {
|
||||
return clean(
|
||||
{
|
||||
id: short().generate(),
|
||||
reason: {
|
||||
code: c['reason.code'],
|
||||
category: { [c['reason.category']]: {} },
|
||||
},
|
||||
levy: {
|
||||
amount: Number(c['levy.amount']),
|
||||
currency: {
|
||||
symbolic_code: c['levy.currency.symbolic_code'],
|
||||
},
|
||||
},
|
||||
body:
|
||||
(c['body.amount'] || typeof c['body.amount'] === 'number') &&
|
||||
c['body.currency.symbolic_code']
|
||||
? {
|
||||
amount: Number(c['body.amount']),
|
||||
currency: {
|
||||
symbolic_code: c['body.currency.symbolic_code'],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
transaction_info: clean(
|
||||
{
|
||||
id: c['transaction_info.id'],
|
||||
timestamp: c['transaction_info.timestamp'],
|
||||
extra: c['transaction_info.extra'],
|
||||
additional_info: c['transaction_info.additional_info']
|
||||
? JSON.parse(c['transaction_info.additional_info'])
|
||||
: undefined,
|
||||
},
|
||||
true,
|
||||
),
|
||||
external_id: c.external_id,
|
||||
context: clean(
|
||||
{
|
||||
type: c['context.type'],
|
||||
data: c['context.data'],
|
||||
},
|
||||
true,
|
||||
),
|
||||
occurred_at: c['occurred_at'],
|
||||
},
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
import { CodegenClient } from '@vality/domain-proto/internal/payment_processing-Invoicing';
|
||||
import { clean } from '@vality/ng-core';
|
||||
import isNil from 'lodash-es/isNil';
|
||||
import short from 'short-uuid';
|
||||
|
||||
import { CsvChargeback } from '../types/csv-chargeback';
|
||||
|
||||
export function getCreateChargebackArgs(
|
||||
c: CsvChargeback,
|
||||
): Parameters<CodegenClient['CreateChargeback']> {
|
||||
return [
|
||||
c.invoice_id,
|
||||
c.payment_id,
|
||||
clean(
|
||||
{
|
||||
id: short().generate(),
|
||||
reason: {
|
||||
code: c['reason.code'],
|
||||
category: { [c['reason.category']]: {} },
|
||||
},
|
||||
levy: {
|
||||
amount: Number(c['levy.amount']),
|
||||
currency: {
|
||||
symbolic_code: c['levy.currency.symbolic_code'],
|
||||
},
|
||||
},
|
||||
body:
|
||||
!isNil(c['body.amount']) && c['body.currency.symbolic_code']
|
||||
? {
|
||||
amount: Number(c['body.amount']),
|
||||
currency: {
|
||||
symbolic_code: c['body.currency.symbolic_code'],
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
transaction_info: clean(
|
||||
{
|
||||
id: c['transaction_info.id'],
|
||||
timestamp: c['transaction_info.timestamp'],
|
||||
extra: c['transaction_info.extra']
|
||||
? new Map(JSON.parse(c['transaction_info.extra']))
|
||||
: undefined,
|
||||
additional_info: c['transaction_info.additional_info']
|
||||
? JSON.parse(c['transaction_info.additional_info'])
|
||||
: undefined,
|
||||
},
|
||||
true,
|
||||
),
|
||||
external_id: c.external_id,
|
||||
context: clean(
|
||||
{
|
||||
type: c['context.type'],
|
||||
data: c['context.data'],
|
||||
},
|
||||
true,
|
||||
),
|
||||
occurred_at: c['occurred_at'],
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
];
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
[extensions]="extensions"
|
||||
[formControl]="control"
|
||||
namespace="deposit"
|
||||
noChangeKind
|
||||
noToolbar
|
||||
type="DepositParams"
|
||||
></cc-fistful-thrift-form>
|
||||
<v-dialog-actions>
|
@ -8,11 +8,11 @@ import { BehaviorSubject, of } from 'rxjs';
|
||||
import { first, map, switchMap } from 'rxjs/operators';
|
||||
import { Overwrite } from 'utility-types';
|
||||
|
||||
import { SourceCash } from '../../../../components/source-cash-field';
|
||||
import { DepositManagementService } from '../../../api/deposit';
|
||||
import { MetadataFormExtension } from '../../../shared/components/metadata-form';
|
||||
import { UserInfoBasedIdGeneratorService } from '../../../shared/services';
|
||||
import { FetchSourcesService } from '../../sources';
|
||||
import { SourceCash } from '../../../../../components/source-cash-field';
|
||||
import { DepositManagementService } from '../../../../api/deposit';
|
||||
import { MetadataFormExtension } from '../../../../shared/components/metadata-form';
|
||||
import { UserInfoBasedIdGeneratorService } from '../../../../shared/services';
|
||||
import { FetchSourcesService } from '../../../sources';
|
||||
|
||||
@Component({
|
||||
templateUrl: 'create-deposit-dialog.component.html',
|
||||
@ -57,17 +57,19 @@ export class CreateDepositDialogComponent extends DialogSuperclass<CreateDeposit
|
||||
(sources) =>
|
||||
sources.find((s) => s.id === sourceCash.sourceId).currency_symbolic_code,
|
||||
),
|
||||
map(
|
||||
(symbolicCode): DepositParams => ({
|
||||
...value,
|
||||
source_id: sourceCash.sourceId,
|
||||
body: {
|
||||
amount: sourceCash.amount,
|
||||
currency: { symbolic_code: symbolicCode },
|
||||
switchMap((symbolicCode) =>
|
||||
this.depositManagementService.Create(
|
||||
{
|
||||
...value,
|
||||
source_id: sourceCash.sourceId,
|
||||
body: {
|
||||
amount: sourceCash.amount,
|
||||
currency: { symbolic_code: symbolicCode },
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Map(),
|
||||
),
|
||||
),
|
||||
switchMap((params) => this.depositManagementService.Create(params, new Map())),
|
||||
progressTo(this.progress$),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
@ -9,10 +9,10 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { DialogModule } from '@vality/ng-core';
|
||||
|
||||
import { SourceCashFieldComponent } from '../../../../components/source-cash-field';
|
||||
import { CurrencySourceFieldComponent } from '../../../shared/components/currency-source-field';
|
||||
import { FistfulThriftFormComponent } from '../../../shared/components/fistful-thrift-form';
|
||||
import { UserInfoBasedIdGeneratorModule } from '../../../shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
|
||||
import { SourceCashFieldComponent } from '../../../../../components/source-cash-field';
|
||||
import { CurrencySourceFieldComponent } from '../../../../shared/components/currency-source-field';
|
||||
import { FistfulThriftFormComponent } from '../../../../shared/components/fistful-thrift-form';
|
||||
import { UserInfoBasedIdGeneratorModule } from '../../../../shared/services/user-info-based-id-generator/user-info-based-id-generator.module';
|
||||
|
||||
import { CreateDepositDialogComponent } from './create-deposit-dialog.component';
|
||||
|
@ -0,0 +1,21 @@
|
||||
<v-dialog [progress]="progress$ | async" title="Create deposits">
|
||||
<cc-upload-csv [props]="props" (selectedChange)="selected = $event"></cc-upload-csv>
|
||||
<v-dialog-actions>
|
||||
<button
|
||||
*ngIf="successfully?.length"
|
||||
[disabled]="!!(progress$ | async)"
|
||||
mat-button
|
||||
(click)="closeWithSuccess()"
|
||||
>
|
||||
Close and find {{ successfully.length }} successful deposits
|
||||
</button>
|
||||
<button
|
||||
[disabled]="!selected?.length || !!(progress$ | async)"
|
||||
color="primary"
|
||||
mat-button
|
||||
(click)="create()"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</v-dialog-actions>
|
||||
</v-dialog>
|
@ -0,0 +1,76 @@
|
||||
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 { DepositState } from '@vality/fistful-proto/internal/deposit';
|
||||
import {
|
||||
DialogSuperclass,
|
||||
NotifyLogService,
|
||||
DEFAULT_DIALOG_CONFIG,
|
||||
forkJoinToResult,
|
||||
DialogModule,
|
||||
} from '@vality/ng-core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { UploadCsvComponent } from '../../../../../components/upload-csv';
|
||||
import { DepositManagementService } from '../../../../api/deposit';
|
||||
|
||||
import { CSV_DEPOSIT_PROPS, CsvDeposit } from './types/csv-deposit';
|
||||
import { getCreateDepositArgs } from './utils/get-create-deposit-args';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'cc-create-deposits-by-file-dialog',
|
||||
templateUrl: './create-deposits-by-file-dialog.component.html',
|
||||
imports: [DialogModule, UploadCsvComponent, CommonModule, MatButton],
|
||||
})
|
||||
export class CreateDepositsByFileDialogComponent extends DialogSuperclass<
|
||||
CreateDepositsByFileDialogComponent,
|
||||
void,
|
||||
DepositState[]
|
||||
> {
|
||||
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
|
||||
|
||||
progress$ = new BehaviorSubject(0);
|
||||
selected: CsvDeposit[] = [];
|
||||
successfully: DepositState[] = [];
|
||||
props = CSV_DEPOSIT_PROPS;
|
||||
|
||||
constructor(
|
||||
private depositManagementService: DepositManagementService,
|
||||
private log: NotifyLogService,
|
||||
private destroyRef: DestroyRef,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
create() {
|
||||
const selected = this.selected;
|
||||
forkJoinToResult(
|
||||
selected.map((c) => this.depositManagementService.Create(...getCreateDepositArgs(c))),
|
||||
this.progress$,
|
||||
)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.successfully.push(...res.filter((c) => !c.hasError).map((c) => c.result));
|
||||
const withError = res.filter((c) => c.hasError);
|
||||
if (withError.length) {
|
||||
this.log.error(
|
||||
withError.map((c) => c.error),
|
||||
`Creating ${withError.length} deposits ended in an error. They were re-selected in the table.`,
|
||||
);
|
||||
this.selected = withError.map((c) => selected[c.index]);
|
||||
} else {
|
||||
this.log.successOperation('create', 'deposits');
|
||||
this.closeWithSuccess();
|
||||
}
|
||||
},
|
||||
error: (err) => this.log.error(err),
|
||||
});
|
||||
}
|
||||
|
||||
override closeWithSuccess() {
|
||||
super.closeWithSuccess(this.successfully);
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { DeepReadonly } from 'utility-types';
|
||||
|
||||
import { CsvProps, CsvObject } from '../../../../../../components/upload-csv';
|
||||
|
||||
export const CSV_DEPOSIT_PROPS = {
|
||||
required: ['wallet_id', 'source_id', 'body.amount', 'body.currency'],
|
||||
optional: ['external_id', 'description', 'metadata'],
|
||||
} as const satisfies DeepReadonly<CsvProps>;
|
||||
|
||||
export type CsvDeposit = CsvObject<
|
||||
(typeof CSV_DEPOSIT_PROPS)['required'][number],
|
||||
(typeof CSV_DEPOSIT_PROPS)['optional'][number]
|
||||
>;
|
@ -0,0 +1,29 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CodegenClient } from '@vality/fistful-proto/internal/deposit-Management';
|
||||
import { clean } from '@vality/ng-core';
|
||||
|
||||
import { UserInfoBasedIdGeneratorService } from '../../../../../shared/services';
|
||||
import { CsvDeposit } from '../types/csv-deposit';
|
||||
|
||||
export function getCreateDepositArgs(c: CsvDeposit): Parameters<CodegenClient['Create']> {
|
||||
const userInfoBasedIdGeneratorService = inject(UserInfoBasedIdGeneratorService);
|
||||
return [
|
||||
clean(
|
||||
{
|
||||
id: userInfoBasedIdGeneratorService.getUsernameBasedId(),
|
||||
wallet_id: c.wallet_id,
|
||||
source_id: c.source_id,
|
||||
body: {
|
||||
amount: Number(c['body.amount']),
|
||||
currency: { symbolic_code: c['body.currency'] },
|
||||
},
|
||||
external_id: c.external_id,
|
||||
metadata: c.metadata ? JSON.parse(c.metadata) : undefined,
|
||||
description: c.description,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
),
|
||||
new Map(),
|
||||
];
|
||||
}
|
@ -31,6 +31,9 @@
|
||||
(update)="reload($event)"
|
||||
>
|
||||
<v-table-actions>
|
||||
<button color="primary" mat-raised-button (click)="createByFile()">
|
||||
Create by file
|
||||
</button>
|
||||
<button color="primary" mat-raised-button (click)="createDeposit()">Create</button>
|
||||
</v-table-actions>
|
||||
</v-table>
|
||||
|
@ -27,7 +27,8 @@ import { QueryDsl } from '../../api/fistful-stat';
|
||||
import { createCurrencyColumn } from '../../shared';
|
||||
import { DATE_RANGE_DAYS, DEBOUNCE_TIME_MS } from '../../tokens';
|
||||
|
||||
import { CreateDepositDialogComponent } from './create-deposit-dialog/create-deposit-dialog.component';
|
||||
import { CreateDepositDialogComponent } from './components/create-deposit-dialog/create-deposit-dialog.component';
|
||||
import { CreateDepositsByFileDialogComponent } from './components/create-deposits-by-file-dialog/create-deposits-by-file-dialog.component';
|
||||
import { FetchDepositsService } from './services/fetch-deposits/fetch-deposits.service';
|
||||
|
||||
const REVERT_STATUS: { [N in RevertStatus]: string } = {
|
||||
@ -133,13 +134,13 @@ export class DepositsComponent implements OnInit {
|
||||
@Inject(DATE_RANGE_DAYS) private dateRangeDays: number,
|
||||
@Inject(DEBOUNCE_TIME_MS) private debounceTimeMs: number,
|
||||
private qp: QueryParamsService<object>,
|
||||
private destroyRef: DestroyRef,
|
||||
private dr: DestroyRef,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.filtersForm.patchValue(this.qp.params);
|
||||
getValueChanges(this.filtersForm)
|
||||
.pipe(debounceTimeWithFirst(this.debounceTimeMs), takeUntilDestroyed(this.destroyRef))
|
||||
.pipe(debounceTimeWithFirst(this.debounceTimeMs), takeUntilDestroyed(this.dr))
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
@ -151,7 +152,7 @@ export class DepositsComponent implements OnInit {
|
||||
.afterClosed()
|
||||
.pipe(
|
||||
filter((res) => res.status === DialogResponseStatus.Success),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
takeUntilDestroyed(this.dr),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
@ -178,4 +179,19 @@ export class DepositsComponent implements OnInit {
|
||||
more() {
|
||||
this.fetchDepositsService.more();
|
||||
}
|
||||
|
||||
createByFile() {
|
||||
this.dialog
|
||||
.open(CreateDepositsByFileDialogComponent)
|
||||
.afterClosed()
|
||||
.pipe(
|
||||
filter((res) => res.status === DialogResponseStatus.Success),
|
||||
takeUntilDestroyed(this.dr),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.filtersForm.reset({
|
||||
dateRange: createDateRangeToToday(this.dateRangeDays),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { PageLayoutModule, WalletFieldModule } from '../../shared';
|
||||
import { CurrencyFieldComponent } from '../../shared/components/currency-field';
|
||||
import { MerchantFieldModule } from '../../shared/components/merchant-field';
|
||||
|
||||
import { CreateDepositDialogModule } from './create-deposit-dialog/create-deposit-dialog.module';
|
||||
import { CreateDepositDialogModule } from './components/create-deposit-dialog/create-deposit-dialog.module';
|
||||
import { DepositsRoutingModule } from './deposits-routing.module';
|
||||
import { DepositsComponent } from './deposits.component';
|
||||
|
||||
|
1
src/components/upload-csv/index.ts
Normal file
1
src/components/upload-csv/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './upload-csv.component';
|
46
src/components/upload-csv/upload-csv.component.html
Normal file
46
src/components/upload-csv/upload-csv.component.html
Normal file
@ -0,0 +1,46 @@
|
||||
<div style="display: flex; flex-direction: column; gap: 16px">
|
||||
<mat-checkbox [formControl]="hasHeaderControl">CSV with header</mat-checkbox>
|
||||
|
||||
<v-file-upload [extensions]="['csv']" (upload)="loadFile($event)"></v-file-upload>
|
||||
|
||||
<mat-accordion>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>Default format</mat-panel-title>
|
||||
</mat-expansion-panel-header>
|
||||
<div class="mat-body-1">
|
||||
Header for CSV file (or if without it, then the order should be like this):
|
||||
</div>
|
||||
<div class="mat-body-1">
|
||||
<code
|
||||
*ngIf="props().required"
|
||||
style="word-wrap: break-word; word-break: break-word; font-weight: bold"
|
||||
>{{ props().required.join(delimiter) }}</code
|
||||
><code
|
||||
*ngIf="props().optional"
|
||||
style="word-wrap: break-word; word-break: break-word; font-style: italic"
|
||||
>{{ props().required && props().optional ? delimiter : '' }}</code
|
||||
><code
|
||||
*ngIf="props().optional"
|
||||
style="word-wrap: break-word; word-break: break-word; font-style: italic"
|
||||
>{{ props().optional.join(delimiter) }}</code
|
||||
>
|
||||
</div>
|
||||
<div class="mat-body-1">
|
||||
<ul>
|
||||
<li>Required columns are indicated in bold.</li>
|
||||
<li>Separator: "{{ delimiter }}"</li>
|
||||
<li *ngFor="let desc of formatDescription() || []">{{ desc }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</mat-accordion>
|
||||
|
||||
<v-table
|
||||
[(rowSelected)]="selectedCsv"
|
||||
[columns]="columns()"
|
||||
[data]="data$ | async"
|
||||
noActions
|
||||
rowSelectable
|
||||
></v-table>
|
||||
</div>
|
148
src/components/upload-csv/upload-csv.component.ts
Normal file
148
src/components/upload-csv/upload-csv.component.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
Output,
|
||||
EventEmitter,
|
||||
type OnInit,
|
||||
input,
|
||||
computed,
|
||||
DestroyRef,
|
||||
model,
|
||||
Injector,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule, FormControl } from '@angular/forms';
|
||||
import { MatCheckbox } from '@angular/material/checkbox';
|
||||
import { MatAccordion, MatExpansionModule } from '@angular/material/expansion';
|
||||
import {
|
||||
FileUploadModule,
|
||||
TableModule,
|
||||
loadFileContent,
|
||||
getValueChanges,
|
||||
NotifyLogService,
|
||||
Column,
|
||||
} from '@vality/ng-core';
|
||||
import startCase from 'lodash-es/startCase';
|
||||
import { BehaviorSubject, combineLatest, merge } from 'rxjs';
|
||||
import { switchMap, map, tap, shareReplay } from 'rxjs/operators';
|
||||
|
||||
import { parseCsv, unifyCsvItems } from '../../utils';
|
||||
|
||||
const DEFAULT_DELIMITER = ';';
|
||||
|
||||
export type CsvProps<R extends string = string, O extends string = string> = {
|
||||
required?: R[];
|
||||
optional?: O[];
|
||||
};
|
||||
|
||||
export type CsvObject<R extends string = string, O extends string = string> = Record<R, string> &
|
||||
Partial<Record<O, string>>;
|
||||
|
||||
function getCsvObjectErrors<R extends string, O extends string>(
|
||||
props: CsvProps<R, O>,
|
||||
data: CsvObject<R, O>[],
|
||||
): null | { required: R[] } {
|
||||
const needRequiredProps = new Set<R>();
|
||||
for (const item of data) {
|
||||
for (const p of props.required ?? []) {
|
||||
if (!(p in item)) {
|
||||
needRequiredProps.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return needRequiredProps.size ? { required: Array.from(needRequiredProps) } : null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'cc-upload-csv',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatCheckbox,
|
||||
ReactiveFormsModule,
|
||||
MatAccordion,
|
||||
FileUploadModule,
|
||||
MatExpansionModule,
|
||||
TableModule,
|
||||
CommonModule,
|
||||
],
|
||||
templateUrl: './upload-csv.component.html',
|
||||
styles: ``,
|
||||
})
|
||||
export class UploadCsvComponent<R extends string, O extends string> implements OnInit {
|
||||
props = input<CsvProps<R, O>>({});
|
||||
formatDescription = input<string[]>();
|
||||
|
||||
selected = input<CsvObject<R, O>[]>([]);
|
||||
@Output() selectedChange = new EventEmitter<CsvObject<R, O>[]>();
|
||||
|
||||
delimiter = DEFAULT_DELIMITER;
|
||||
propsList = computed<string[]>(() => [
|
||||
...(this.props().required ?? []),
|
||||
...(this.props().optional ?? []),
|
||||
]);
|
||||
selectedCsv = model<CsvObject<R, O>[]>();
|
||||
|
||||
hasHeaderControl = new FormControl(null, { nonNullable: true });
|
||||
upload$ = new BehaviorSubject<File | null>(null);
|
||||
data$ = combineLatest([this.upload$, getValueChanges(this.hasHeaderControl)]).pipe(
|
||||
switchMap(([file]) => loadFileContent(file)),
|
||||
map((content) =>
|
||||
parseCsv(content, {
|
||||
header: this.hasHeaderControl.value || false,
|
||||
delimiter: this.delimiter,
|
||||
}),
|
||||
),
|
||||
tap((d) => {
|
||||
if (!d.errors.length) {
|
||||
return;
|
||||
}
|
||||
if (d.errors.length === 1) {
|
||||
this.log.error(d.errors[0]);
|
||||
}
|
||||
this.log.error(new Error(d.errors.map((e) => e.message).join('. ')));
|
||||
}),
|
||||
map((d) => {
|
||||
const data: CsvObject<R, O>[] = unifyCsvItems(d?.data, this.propsList());
|
||||
const errors = getCsvObjectErrors(this.props(), data);
|
||||
if (!errors) {
|
||||
return data;
|
||||
}
|
||||
this.log.error(
|
||||
`Missing required properties: ${errors.required.join(
|
||||
', ',
|
||||
)}. Perhaps you incorrectly checked the checkbox to have or not a header (the first element does not have at least an invoice ID).`,
|
||||
);
|
||||
return [];
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
columns = computed<Column<CsvObject<R, O>>[]>(() =>
|
||||
this.propsList().map((p) => ({
|
||||
field: p,
|
||||
header: startCase(p),
|
||||
})),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private log: NotifyLogService,
|
||||
private dr: DestroyRef,
|
||||
private injector: Injector,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
merge(this.data$, toObservable(this.selected, { injector: this.injector }))
|
||||
.pipe(takeUntilDestroyed(this.dr))
|
||||
.subscribe((v) => {
|
||||
this.selectedCsv.set(v || []);
|
||||
});
|
||||
toObservable(this.selectedCsv, { injector: this.injector })
|
||||
.pipe(takeUntilDestroyed(this.dr))
|
||||
.subscribe((v) => {
|
||||
this.selectedChange.emit(v);
|
||||
});
|
||||
}
|
||||
|
||||
async loadFile(file: File) {
|
||||
this.upload$.next(file);
|
||||
}
|
||||
}
|
@ -1,3 +1,2 @@
|
||||
export * from './parse-csv';
|
||||
export * from './types/csv-item';
|
||||
export * from './utils/unify-csv-items';
|
||||
|
@ -1,2 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type CsvItem<TPropertyKey extends string = string> = Record<TPropertyKey[number], any>;
|
Loading…
Reference in New Issue
Block a user