IMP-68: Bulk create chargebacks (#236)

This commit is contained in:
Rinat Arsaev 2023-07-05 23:09:06 +04:00 committed by GitHub
parent 2672d9c670
commit ecdd62b761
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 328 additions and 12 deletions

View File

@ -52,7 +52,8 @@
"@vality/repairer-proto",
"@vality/fistful-proto",
"@vality/file-storage-proto",
"@vality/thrift-ts"
"@vality/thrift-ts",
"papaparse"
]
},
"configurations": {

View File

@ -2,5 +2,5 @@
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"import": "node_modules/@vality/cspell-config/cspell.config.js",
"words": ["nspkmir", "applepay", "samsungpay", "googlepay", "submain"]
"words": ["nspkmir", "applepay", "samsungpay", "googlepay", "submain", "papaparse"]
}

24
package-lock.json generated
View File

@ -29,7 +29,7 @@
"@vality/dominant-cache-proto": "2.0.1-99f38c9.0",
"@vality/fistful-proto": "2.0.1-4ff4ea3.0",
"@vality/magista-proto": "2.0.1-cf0eff8.0",
"@vality/ng-core": "16.1.1-pr-28-a47f5a4.0",
"@vality/ng-core": "16.1.1-pr-28-042958a.0",
"@vality/payout-manager-proto": "2.0.1-b079679.0",
"@vality/repairer-proto": "2.0.1-8f7973d.0",
"@vality/thrift-ts": "2.4.1-8ad5123.0",
@ -46,6 +46,7 @@
"moment": "2.29.4",
"monaco-editor": "0.21.2",
"ngx-mat-select-search": "7.0.2",
"papaparse": "5.4.1",
"rxjs": "7.8.1",
"short-uuid": "4.2.2",
"tslib": "2.3.1",
@ -65,6 +66,7 @@
"@types/jasmine": "4.0.3",
"@types/jwt-decode": "2.2.1",
"@types/lodash-es": "4.17.6",
"@types/papaparse": "5.3.7",
"@vality/cspell-config": "0.1.1-pr-15-020121f.0",
"@vality/eslint-config": "1.0.1-pr-27-18c018c.0",
"@vality/prettier-config": "0.1.1-pr-15-225ffc3.0",
@ -5262,6 +5264,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.3.1.tgz",
"integrity": "sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg=="
},
"node_modules/@types/papaparse": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.7.tgz",
"integrity": "sha512-f2HKmlnPdCvS0WI33WtCs5GD7X1cxzzS/aduaxSu3I7TbhWlENjSPs6z5TaB9K0J+BH1jbmqTaM+ja5puis4wg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz",
@ -5776,9 +5787,9 @@
"integrity": "sha512-59ncaJpt7tXFLOq9KrDu4OgrDQr9vTQ3j30T0hjN+ZIsPBsE+lld/pGKASWLLQfwvTtvp9laAuKgQGX9GuvIiQ=="
},
"node_modules/@vality/ng-core": {
"version": "16.1.1-pr-28-a47f5a4.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-16.1.1-pr-28-a47f5a4.0.tgz",
"integrity": "sha512-VVx7UdNT/8h0E0q+swLHD7rarm8wMXwBjINFYz47KEkQrWGVw7NFP38r6thFAQMWB9Knf5mR4RykdJ+LdDFduQ==",
"version": "16.1.1-pr-28-042958a.0",
"resolved": "https://registry.npmjs.org/@vality/ng-core/-/ng-core-16.1.1-pr-28-042958a.0.tgz",
"integrity": "sha512-gIvMwBCj2TRTWuAj2MevzfjZijC+X8qoh5ByBDNtqPhBOPBa/AxARtT7CV8Si92ww4NTlyxfZ0QhSStQaRJSmQ==",
"dependencies": {
"@ng-matero/extensions": "^16.0.0",
"@s-libs/js-core": "^16.0.0",
@ -17655,6 +17666,11 @@
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/papaparse": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz",
"integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw=="
},
"node_modules/parallel-transform": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/parallel-transform/-/parallel-transform-1.2.0.tgz",

View File

@ -37,7 +37,7 @@
"@vality/dominant-cache-proto": "2.0.1-99f38c9.0",
"@vality/fistful-proto": "2.0.1-4ff4ea3.0",
"@vality/magista-proto": "2.0.1-cf0eff8.0",
"@vality/ng-core": "16.1.1-pr-28-a47f5a4.0",
"@vality/ng-core": "16.1.1-pr-28-042958a.0",
"@vality/payout-manager-proto": "2.0.1-b079679.0",
"@vality/repairer-proto": "2.0.1-8f7973d.0",
"@vality/thrift-ts": "2.4.1-8ad5123.0",
@ -54,6 +54,7 @@
"moment": "2.29.4",
"monaco-editor": "0.21.2",
"ngx-mat-select-search": "7.0.2",
"papaparse": "5.4.1",
"rxjs": "7.8.1",
"short-uuid": "4.2.2",
"tslib": "2.3.1",
@ -73,6 +74,7 @@
"@types/jasmine": "4.0.3",
"@types/jwt-decode": "2.2.1",
"@types/lodash-es": "4.17.6",
"@types/papaparse": "5.3.7",
"@vality/cspell-config": "0.1.1-pr-15-020121f.0",
"@vality/eslint-config": "1.0.1-pr-27-18c018c.0",
"@vality/prettier-config": "0.1.1-pr-15-225ffc3.0",

View File

@ -47,6 +47,6 @@
(update)="load($event)"
>
<!-- <button color="primary" mat-button>Change statuses</button>-->
<!-- <button color="primary" mat-button (click)="create()">Create</button>-->
<button color="primary" mat-button (click)="create()">Create by file</button>
</cc-chargebacks-table>
</cc-page-layout>

View File

@ -12,10 +12,11 @@ import {
isEqualDateRange,
countProps,
DialogService,
DialogResponseStatus,
} from '@vality/ng-core';
import { endOfDay } from 'date-fns';
import merge from 'lodash-es/merge';
import { debounceTime } from 'rxjs';
import { debounceTime, filter } from 'rxjs';
import { startWith } from 'rxjs/operators';
import {
@ -24,7 +25,7 @@ import {
CHARGEBACK_CATEGORIES,
} from '@cc/app/api/fistful-stat';
import { CreateChargebackDialogComponent } from './components/create-chargeback-dialog/create-chargeback-dialog.component';
import { CreateChargebacksByFileDialogComponent } from './components/create-chargebacks-by-file-dialog/create-chargebacks-by-file-dialog.component';
import { FetchChargebacksService } from './fetch-chargebacks.service';
@UntilDestroy()
@ -112,6 +113,15 @@ export class ChargebacksComponent implements OnInit {
}
create() {
this.dialog.open(CreateChargebackDialogComponent, {});
this.dialog
.open(CreateChargebacksByFileDialogComponent)
.afterClosed()
.pipe(
filter((res) => res.status === DialogResponseStatus.Success),
untilDestroyed(this)
)
.subscribe(() => {
this.load();
});
}
}

View File

@ -3,8 +3,11 @@ import { NgModule } from '@angular/core';
import { FlexModule } from '@angular/flex-layout';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatOptionModule } from '@angular/material/core';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import {
TableModule,
@ -14,6 +17,7 @@ import {
InputFieldModule,
SelectFieldModule,
DialogModule,
FileUploadModule,
} from '@vality/ng-core';
import { PageLayoutModule, ShopFieldModule, ThriftPipesModule } from '../../shared';
@ -26,12 +30,14 @@ import { ChargebacksRoutingModule } from './chargebacks-routing.module';
import { ChargebacksComponent } from './chargebacks.component';
import { ChargebacksTableComponent } from './components/chargebacks-table/chargebacks-table.component';
import { CreateChargebackDialogComponent } from './components/create-chargeback-dialog/create-chargeback-dialog.component';
import { CreateChargebacksByFileDialogComponent } from './components/create-chargebacks-by-file-dialog/create-chargebacks-by-file-dialog.component';
@NgModule({
declarations: [
ChargebacksComponent,
ChargebacksTableComponent,
CreateChargebackDialogComponent,
CreateChargebacksByFileDialogComponent,
],
imports: [
CommonModule,
@ -56,6 +62,10 @@ import { CreateChargebackDialogComponent } from './components/create-chargeback-
DialogModule,
DomainThriftFormComponent,
FlexModule,
FileUploadModule,
MatExpansionModule,
MatInputModule,
MatCheckboxModule,
],
})
export class ChargebacksModule {}

View File

@ -1,4 +1,4 @@
<v-dialog title="Create chargebacks">
<v-dialog title="Create chargeback">
<div fxLayout="column" fxLayoutGap="16px">
<v-input-field [formControl]="invoiceControl" label="Invoice Id" required></v-input-field>
<v-input-field [formControl]="paymentControl" label="Payment Id" required></v-input-field>

View File

@ -0,0 +1,43 @@
<v-dialog [progress]="progress$ | async" title="Create chargebacks">
<div fxLayout="column" fxLayoutGap="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">{{ 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
[columns]="columns"
[data]="chargebacks$ | async"
[rowSelected]="selectedChargebacks"
noActions
rowSelectable
(rowSelectionChange)="selectedChargebacks = $event"
></v-table>
</div>
<v-dialog-actions>
<button
[disabled]="!selectedChargebacks?.length"
color="primary"
mat-raised-button
(click)="create()"
>
Create
</button>
</v-dialog-actions>
</v-dialog>

View File

@ -0,0 +1,114 @@
import { Component, Injector, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import {
DialogSuperclass,
NotifyLogService,
loadFileContent,
DEFAULT_DIALOG_CONFIG,
Column,
forkJoinToResult,
} from '@vality/ng-core';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { switchMap, map, shareReplay, tap, startWith } from 'rxjs/operators';
import { InvoicingService } from '@cc/app/api/payment-processing';
import { parseCsv, unifyCsvItems } from '@cc/utils';
import { CsvChargeback } from './types/csv-chargeback';
import { CSV_CHARGEBACK_PROPS } from './types/csv-chargeback-props';
import { csvChargebacksToInvoicePaymentChargebackParams } from './utils/csv-chargebacks-to-invoice-payment-chargeback-params';
@UntilDestroy()
@Component({
selector: 'cc-create-chargebacks-by-file-dialog',
templateUrl: './create-chargebacks-by-file-dialog.component.html',
})
export class CreateChargebacksByFileDialogComponent
extends DialogSuperclass<CreateChargebacksByFileDialogComponent>
implements OnInit
{
static defaultDialogConfig = DEFAULT_DIALOG_CONFIG.large;
hasHeaderControl = new FormControl(true);
progress$ = new BehaviorSubject(0);
upload$ = new BehaviorSubject<File | null>(null);
columns: Column<CsvChargeback>[] = CSV_CHARGEBACK_PROPS.map((c) => ({ field: c, header: c }));
defaultFormat = CSV_CHARGEBACK_PROPS.join(';');
selectedChargebacks: CsvChargeback[] = [];
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 [];
}),
shareReplay({ refCount: true, bufferSize: 1 })
);
constructor(
injector: Injector,
private invoicingService: InvoicingService,
private log: NotifyLogService
) {
super(injector);
}
ngOnInit() {
this.chargebacks$.pipe(untilDestroyed(this)).subscribe((c) => {
this.selectedChargebacks = c || [];
});
}
create() {
const selected = this.selectedChargebacks;
forkJoinToResult(
selected.map((c) =>
this.invoicingService.CreateChargeback(
c.invoice_id,
c.payment_id,
csvChargebacksToInvoicePaymentChargebackParams(c)
)
),
2,
this.progress$
)
.pipe(untilDestroyed(this))
.subscribe({
next: (res) => {
const chargebacksWithError = res.filter((c) => c.hasError);
if (chargebacksWithError.length) {
this.log.error(
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]
);
return;
}
this.log.successOperation('create', 'chargebacks');
this.closeWithSuccess();
},
error: (err) => this.log.error(err),
});
}
async loadFile(file: File) {
this.upload$.next(file);
}
}

View File

@ -0,0 +1,24 @@
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;

View File

@ -0,0 +1,5 @@
import { CsvItem } from '@cc/utils';
import { CSV_CHARGEBACK_PROPS } from './csv-chargeback-props';
export type CsvChargeback = CsvItem<(typeof CSV_CHARGEBACK_PROPS)[number]>;

View File

@ -0,0 +1,56 @@
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().uuid(),
reason: {
code: c['reason.code'],
category: { [c['reason.category']]: {} },
},
levy: {
amount: c['levy.amount'],
currency: {
symbolic_code: c['levy.currency.symbolic_code'],
},
},
body: clean(
{
amount: c['body.amount'],
currency: {
symbolic_code: c['body.currency.symbolic_code'],
},
},
true
),
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
);
}

View File

@ -0,0 +1,11 @@
import { clean, isEmpty } from '@vality/ng-core';
import isObject from 'lodash-es/isObject';
function isEmptyThrift(value: unknown): boolean {
if (isObject(value) && value.constructor === Object) return false;
return isEmpty(value);
}
export function cleanThrift<T extends object>(obj: T) {
return clean(obj, false, false, (v) => !isEmptyThrift(v));
}

View File

@ -1,2 +1,3 @@
export * from './polling-conditions';
export * from './deposit-status';
export * from './clean-thrift';

3
src/utils/csv/index.ts Normal file
View File

@ -0,0 +1,3 @@
export * from './parse-csv';
export * from './types/csv-item';
export * from './utils/unify-csv-items';

View File

@ -0,0 +1,5 @@
import Papa, { ParseConfig } from 'papaparse';
export function parseCsv(content: string, config?: ParseConfig) {
return Papa.parse(content, config);
}

View File

@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CsvItem<TPropertyKey extends string = string> = Record<TPropertyKey[number], any>;

View File

@ -0,0 +1,12 @@
export function unifyCsvItems<T>(
csv: T[] | string[][],
defaultProps: readonly (keyof T)[] | (keyof T)[]
): T[] {
if (!Array.isArray(csv)) return [];
if (Array.isArray(csv?.[0])) {
return csv.map((d) =>
Object.fromEntries(d.map((prop, idx) => [defaultProps[idx], prop]))
) as T[];
}
return csv as T[];
}

View File

@ -14,3 +14,4 @@ export * from './get-enum-keys';
export * from './is-nil-or-empty-string';
export * from './enumerate';
export * from './thrift-instance';
export * from './csv';