feat: implement payment result handling; refactor payment components and enhance terminal payment logic

This commit is contained in:
2026-05-17 15:21:06 +03:30
parent 6f1ad20cff
commit 78501b907b
13 changed files with 268 additions and 138 deletions
+2 -3
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1.4
# ---------- Build stage ---------- # ---------- Build stage ----------
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
@@ -7,11 +6,11 @@ ARG DIST_DIR=tis
WORKDIR /app WORKDIR /app
RUN npm config set registry "https://hub.megan.ir/npm/"
RUN npm install -g pnpm RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ RUN pnpm install --frozen-lockfile
pnpm config set store-dir /pnpm/store && pnpm install --frozen-lockfile
COPY . . COPY . .
@@ -1,4 +1,5 @@
<div class="w-full min-h-[60svh] p-4 md:p-6"> @defer {
<div class="w-full min-h-[60svh] p-4 md:p-6">
<section class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-surface-200 bg-white"> <section class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-surface-200 bg-white">
<div class="bg-gradient-to-l from-emerald-50 via-teal-50 to-cyan-50 p-5 md:p-7"> <div class="bg-gradient-to-l from-emerald-50 via-teal-50 to-cyan-50 p-5 md:p-7">
<p class="mb-2 inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-semibold text-emerald-700"> <p class="mb-2 inline-flex items-center rounded-full bg-white px-3 py-1 text-xs font-semibold text-emerald-700">
@@ -6,8 +7,8 @@
</p> </p>
<span class="block mb-4 text-xl font-bold text-surface-900 md:text-3xl">{{ brandingInfo.fullTitle }}</span> <span class="block mb-4 text-xl font-bold text-surface-900 md:text-3xl">{{ brandingInfo.fullTitle }}</span>
<p class="mt-3 max-w-2xl text-sm leading-7 text-surface-700 md:text-base"> <p class="mt-3 max-w-2xl text-sm leading-7 text-surface-700 md:text-base">
این نسخه برای استفاده روزانه روی دستگاه های PSP بهینه شده است تا عملیات ثبت و صدور صورتحساب‌‌های مالیاتی با سرعت این نسخه برای استفاده روزانه روی دستگاه های PSP بهینه شده است تا عملیات ثبت و صدور صورتحساب‌‌های مالیاتی با
بالاتر، پایداری بیشتر و تجربه کاربری ساده تر انجام شود. سرعت بالاتر، پایداری بیشتر و تجربه کاربری ساده تر انجام شود.
</p> </p>
</div> </div>
@@ -58,4 +59,5 @@
</p> </p>
</div> </div>
</section> </section>
</div> </div>
}
@@ -45,7 +45,6 @@ export class PosConfigPrintFormComponent extends AbstractForm<
override async ngOnInit() { override async ngOnInit() {
this.loading.set(true); this.loading.set(true);
const initialValues = await this.service.get(); const initialValues = await this.service.get();
console.log('initialValues', initialValues);
this.form.patchValue(initialValues); this.form.patchValue(initialValues);
this.loading.set(false); this.loading.set(false);
@@ -16,7 +16,7 @@
<div class="form-group"> <div class="form-group">
<uikit-field label="تعداد مراحل پرداخت با پایانه" name="pay_by_terminal_steps"> <uikit-field label="تعداد مراحل پرداخت با پایانه" name="pay_by_terminal_steps">
<p-select <p-select
[options]="payByTerminalSteps" [options]="payByTerminalSteps()"
[ngModel]="selectedPayByTerminalStep()" [ngModel]="selectedPayByTerminalStep()"
[ngModelOptions]="{ standalone: true }" [ngModelOptions]="{ standalone: true }"
optionLabel="label" optionLabel="label"
@@ -33,13 +33,14 @@
[label]="$index === 0 ? 'پرداخت با پایانه' : 'پرداخت با پایانه - مرحله ' + ($index + 1)" [label]="$index === 0 ? 'پرداخت با پایانه' : 'پرداخت با پایانه - مرحله ' + ($index + 1)"
type="price" type="price"
[max]="terminalsMax()[$index]" [max]="terminalsMax()[$index]"
[disabled]="payByTerminalSteps()[$index].payed"
> >
<ng-template #suffixTemp> <ng-template #suffixTemp>
<button <button
pButton pButton
size="small" size="small"
type="button" type="button"
[disabled]="!remainedAmount()" [disabled]="!remainedAmount() || payByTerminalSteps()[$index].payed"
(click)="fillTerminalRemained($index)" (click)="fillTerminalRemained($index)"
> >
تمامی بدهی باقی‌مانده تمامی بدهی باقی‌مانده
@@ -12,7 +12,13 @@ import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angu
import { ButtonDirective } from 'primeng/button'; import { ButtonDirective } from 'primeng/button';
import { Select } from 'primeng/select'; import { Select } from 'primeng/select';
import { catchError, throwError } from 'rxjs'; import { catchError, throwError } from 'rxjs';
import { IPayment, IPosOrderResponse, TOrderPaymentTypes } from '../../models'; import {
IPayment,
IPosOrderResponse,
PosPaymentResult,
TerminalSuccessPaymentPayload,
TOrderPaymentTypes,
} from '../../models';
import { PosPaymentBridgeAbstract } from '../../services/payment-bridge.abstract'; import { PosPaymentBridgeAbstract } from '../../services/payment-bridge.abstract';
import { PosPaymentBridgeService } from '../../services/payment-bridge.service'; import { PosPaymentBridgeService } from '../../services/payment-bridge.service';
import { PosLandingStore } from '../../store/main.store'; import { PosLandingStore } from '../../store/main.store';
@@ -48,25 +54,57 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
submitOrderLoading = computed(() => this.store.submitOrderLoading()); submitOrderLoading = computed(() => this.store.submitOrderLoading());
payByTerminalSteps = Array.from({ length: 10 }, (_, i) => ({ payByTerminalSteps = signal(
Array.from({ length: 10 }, (_, i) => ({
value: i + 1, value: i + 1,
payed: false,
info: {} as TerminalSuccessPaymentPayload,
label: `${i + 1} مرحله‌ای`, label: `${i + 1} مرحله‌ای`,
})); amount: 0,
})),
);
selectedPayByTerminalStep = signal(1); selectedPayByTerminalStep = signal(1);
payedInTerminal = signal<Array<number>>([]);
private restorePaymentCallback: Maybe<() => void> = null; private restorePaymentCallback: Maybe<() => void> = null;
private readonly injectedPaymentResultHandler = (payload: unknown) => { private readonly injectedPaymentResultHandler = (payload: PosPaymentResult) => {
alert('پرداخت با موفقیت انجام شد'); if (payload.status === 'SUCCESS') {
this.toastServices.success({ text: 'شد شد شد شد شد' }); const paidStepId = Number(payload.id || 0);
// this.handlePaymentResult(payload); this.payByTerminalSteps.update((prev) =>
prev.map((step, index) => {
if (step.value === paidStepId) {
return {
...step,
payed: true,
amount: this.terminalControls[step.value - 1]?.value || 0,
info: {
customer_card_no: payload.data?.customerCardNO || '',
description: payload.message || '',
rrn: payload.data?.rrn || '',
stan: payload.data?.stan || '',
terminal_id: payload.data?.terminalId || '',
transaction_date_time: new Date(payload.data?.transactionDateTime || ''),
},
};
}
return step;
}),
);
const terminalControl = this.terminalControls[paidStepId - 1];
if (terminalControl) {
terminalControl.disable({ onlySelf: true, emitEvent: false });
}
const amount = Number(
terminalControl?.value || this.extractAmountFromPaymentResult(payload) || 0,
);
this.toastServices.success({ text: `پرداخت با موفقیت انجام شد. مبلغ: ${amount}` });
} else {
this.toastServices.error({ text: payload.message || 'پرداخت با دستگاه انجام نشد.' });
}
}; };
ngAfterViewInit() { ngAfterViewInit() {
console.log('ngOnViewInit');
this.restorePaymentCallback = this.paymentBridge.registerPaymentResultListener( this.restorePaymentCallback = this.paymentBridge.registerPaymentResultListener(
this.injectedPaymentResultHandler, this.injectedPaymentResultHandler,
); );
@@ -118,7 +156,11 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
this.terminalsMax.update((max) => { this.terminalsMax.update((max) => {
const terminalMax = remainedAmount + (terminal.value || 0); const terminalMax = remainedAmount + (terminal.value || 0);
terminal.addValidators([Validators.max(terminalMax)]); terminal.addValidators([Validators.max(terminalMax)]);
if (terminalMax) { if (this.isTerminalStepPaid(i + 1)) {
terminal.disable({
onlySelf: true,
});
} else if (terminalMax) {
terminal.enable({ terminal.enable({
onlySelf: true, onlySelf: true,
}); });
@@ -145,7 +187,13 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
} }
changePayByTerminalSteps(steps: number) { changePayByTerminalSteps(steps: number) {
const normalizedSteps = Math.max(1, Number(steps) || 1); const minSteps = this.getMinAllowedSteps();
const normalizedSteps = Math.max(minSteps, Number(steps) || 1);
if ((Number(steps) || 1) < minSteps) {
this.toastServices.warn({
text: `حداقل تعداد مراحل به دلیل پرداخت های انجام شده ${minSteps} است.`,
});
}
this.selectedPayByTerminalStep.set(normalizedSteps); this.selectedPayByTerminalStep.set(normalizedSteps);
const terminals = this.form.controls.terminals; const terminals = this.form.controls.terminals;
@@ -219,10 +267,24 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
} }
const rawPayment = this.form.getRawValue(); const rawPayment = this.form.getRawValue();
const terminalPayments = this.payByTerminalSteps()
.slice(0, this.selectedPayByTerminalStep())
.filter((step) => step.payed)
.map((step) => ({
amount: Number(this.terminalControls[step.value - 1]?.value || step.amount || 0),
terminal_id: step.info.terminal_id || '',
stan: step.info.stan || '',
rrn: step.info.rrn || '',
response_code: '00',
customer_card_no: step.info.customer_card_no || '',
transaction_date_time: step.info.transaction_date_time || new Date(),
description: step.info.description || '',
}));
const payment: IPayment = { const payment: IPayment = {
cash: Number(rawPayment.cash || 0), cash: Number(rawPayment.cash || 0),
set_off: Number(rawPayment.set_off || 0), set_off: Number(rawPayment.set_off || 0),
terminals: (rawPayment.terminals || []).map((value) => Number(value || 0)), terminals: terminalPayments,
}; };
this.store.setPayment(payment); this.store.setPayment(payment);
@@ -230,6 +292,9 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
const terminalPaymentIsDone = this.checkTerminalPayments(); const terminalPaymentIsDone = this.checkTerminalPayments();
if (terminalPaymentIsDone) { if (terminalPaymentIsDone) {
this.toastService.info({
text: `terminalPayments[0].customer_card_no: ${payment.terminals[0]?.customer_card_no || ''}`,
});
this.store this.store
.submitOrder() .submitOrder()
.pipe( .pipe(
@@ -245,23 +310,15 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
} }
private checkTerminalPayments(): boolean { private checkTerminalPayments(): boolean {
this.form.controls.terminals.controls = this.form.controls.terminals.controls.filter( const pendingTerminalPayments = this.getPendingTerminalPayments();
(control) => control.value, if (pendingTerminalPayments.length) {
);
const terminalPayments = this.form.controls.terminals.controls.map(
(terminal) => terminal.value!,
);
this.selectedPayByTerminalStep.set(terminalPayments.length || 1);
if (terminalPayments.length) {
if (this.nativeBridgeService.isEnabled()) { if (this.nativeBridgeService.isEnabled()) {
for (let i in terminalPayments) { const nextPending = pendingTerminalPayments[0];
const terminal = terminalPayments[i]; this.payByTerminal(nextPending.stepId, nextPending.amount);
this.toastServices.info({ text: 'در حال پردازش پرداخت با دستگاه...', life: 3000 }); this.toastServices.info({
this.payByTerminal(parseInt(i), terminal); text: 'پس از تایید پرداخت هر مرحله، برای مرحله بعد دوباره روی ثبت پرداخت بزنید.',
break; life: 3500,
} });
} else { } else {
this.toastServices.error({ this.toastServices.error({
text: 'امکان ارتباط با دستگاه پرداخت میسر نیست.', text: 'امکان ارتباط با دستگاه پرداخت میسر نیست.',
@@ -278,7 +335,7 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
private payByTerminal(id: number, amount: number) { private payByTerminal(id: number, amount: number) {
const payResult = this.nativeBridgeService.pay({ const payResult = this.nativeBridgeService.pay({
amount, amount,
id: id.toString(), id: String(id),
}); });
// if (!payResult.success) { // if (!payResult.success) {
@@ -288,6 +345,27 @@ export class PosPaymentFormDialogComponent extends AbstractFormDialog<IPayment,
// } // }
} }
private isTerminalStepPaid(stepId: number): boolean {
return this.payByTerminalSteps().some((step) => step.value === stepId && step.payed);
}
private getMinAllowedSteps() {
const paidSteps = this.payByTerminalSteps()
.slice(0, this.selectedPayByTerminalStep())
.filter((step) => step.payed)
.map((step) => step.value);
return paidSteps.length ? Math.max(...paidSteps) : 1;
}
private getPendingTerminalPayments() {
return this.terminalControls
.map((control, index) => ({
stepId: index + 1,
amount: Number(control.value || 0),
}))
.filter(({ stepId, amount }) => amount > 0 && !this.isTerminalStepPaid(stepId));
}
override onSuccess(response: IPosOrderResponse): void {} override onSuccess(response: IPosOrderResponse): void {}
private extractAmountFromPaymentResult(payload: unknown): number | null { private extractAmountFromPaymentResult(payload: unknown): number | null {
@@ -2,4 +2,5 @@ export * from './customer';
export * from './io'; export * from './io';
export * from './payload'; export * from './payload';
export * from './payment'; export * from './payment';
export * from './posPaymentResult';
export * from './types'; export * from './types';
@@ -1,5 +1,5 @@
export interface IPayment { export interface IPayment {
terminals: number[]; terminals: IPaymentTerminal[];
cash: number; cash: number;
set_off: number; set_off: number;
} }
@@ -0,0 +1,22 @@
export interface PosPaymentResult {
id?: string;
status: 'SUCCESS' | 'FAILURE';
message?: string;
data?: {
terminalId: string;
stan: string;
rrn: string;
responseCode: string;
customerCardNO: string;
transactionDateTime: string;
};
}
export interface TerminalSuccessPaymentPayload {
terminal_id: string;
stan: string;
rrn: string;
transaction_date_time: Date;
customer_card_no: string;
description: string;
}
@@ -1,4 +1,6 @@
import { PosPaymentResult } from '../models';
export abstract class PosPaymentBridgeAbstract { export abstract class PosPaymentBridgeAbstract {
abstract registerPaymentResultListener(handler: (payload: unknown) => void): () => void; abstract registerPaymentResultListener(handler: (payload: PosPaymentResult) => void): () => void;
abstract emitPaymentResultForTest(payload: unknown): void; abstract emitPaymentResultForTest(payload: unknown): void;
} }
@@ -1,33 +1,51 @@
import { ToastService } from '@/core/services/toast.service'; import { ToastService } from '@/core/services/toast.service';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { PosPaymentResult } from '../models';
import { PosPaymentBridgeAbstract } from './payment-bridge.abstract'; import { PosPaymentBridgeAbstract } from './payment-bridge.abstract';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PosPaymentBridgeService extends PosPaymentBridgeAbstract { export class PosPaymentBridgeService extends PosPaymentBridgeAbstract {
private readonly toastServices = inject(ToastService); private readonly toastServices = inject(ToastService);
registerPaymentResultListener(handler: (payload: unknown) => void): () => void { registerPaymentResultListener(handler: (payload: PosPaymentResult) => void): () => void {
// const w = window as unknown as {
// WebV?: {
// onPaymentResult?: (payload: unknown) => void;
// AndroidBridge?: {
// onPaymentResult?: (payload: unknown) => void;
// };
// };
// };
this.toastServices.error({ text: '@@@@@@@@@@@@@' });
// @ts-ignore // @ts-ignore
window.WebV = { window.WebV = {
onPaymentResult: () => { onPaymentResult: (payload: PosPaymentResult) => {
this.toastServices.error({ text: 'asdasdsadassadasdas' }); try {
// const parsedPayload: PosPaymentResult = JSON.parse(payload);
// this.toastServices.info({ text: payload.data?.customerCardNO || '', life: 10000 });
// this.toastServices.info({
// text: payload.data?.responseCode || 'No response code',
// life: 10000,
// });
// this.toastServices.info({ text: payload.data?.rrn || 'No response code', life: 10000 });
// this.toastServices.info({ text: payload.data?.stan || 'No response code', life: 10000 });
// this.toastServices.info({
// text: payload.data?.terminalId || 'No response code',
// life: 10000,
// });
// this.toastServices.info({
// text: payload.data?.transactionDateTime || 'No response code',
// life: 10000,
// });
handler(payload);
} catch (e) {
this.toastServices.error({ text: 'Failed to parse payment result', life: 10000 });
this.toastServices.error({
text: e instanceof Error ? e.message : String(e),
life: 10000,
});
this.toastServices.error({ text: typeof payload, life: 10000 });
}
}, },
}; };
return () => {}; return () => {};
} }
emitPaymentResultForTest(payload: unknown): void { emitPaymentResultForTest(payload: PosPaymentResult): void {
const w = window as unknown as { WebV?: { onPaymentResult?: (payload: unknown) => void } }; const w = window as unknown as {
WebV?: { onPaymentResult?: (payload: PosPaymentResult) => void };
};
w.WebV?.onPaymentResult?.(payload); w.WebV?.onPaymentResult?.(payload);
} }
} }
@@ -1,5 +1,6 @@
import { Maybe } from '@/core'; import { Maybe } from '@/core';
// import { ICustomerResponse } from '@/modules/customers/models'; // import { ICustomerResponse } from '@/modules/customers/models';
import { ToastService } from '@/core/services/toast.service';
import { IGoodCategoryResponse, IGoodResponse } from '@/domains/pos/models/good.io'; import { IGoodCategoryResponse, IGoodResponse } from '@/domains/pos/models/good.io';
import { CustomerType } from '@/shared/localEnum/constants/customerTypes'; import { CustomerType } from '@/shared/localEnum/constants/customerTypes';
import { createUUID, JALALI_DATE_FORMATS, nowJalali } from '@/utils'; import { createUUID, JALALI_DATE_FORMATS, nowJalali } from '@/utils';
@@ -55,6 +56,7 @@ export const INITIAL_POS_STATE: IPosLandingState = {
@Injectable({ providedIn: 'any' }) @Injectable({ providedIn: 'any' })
export class PosLandingStore { export class PosLandingStore {
private readonly service = inject(PosService); private readonly service = inject(PosService);
private readonly toastServices = inject(ToastService);
private state$ = signal<IPosLandingState>({ ...INITIAL_POS_STATE }); private state$ = signal<IPosLandingState>({ ...INITIAL_POS_STATE });
@@ -238,6 +240,10 @@ export class PosLandingStore {
total_amount: this.orderPricingInfo().totalAmount, total_amount: this.orderPricingInfo().totalAmount,
}; };
this.toastServices.info({
text: `payment submitted: ${this.payments().terminals[0]?.customer_card_no || ''}`,
});
let res: Maybe<IPosOrderResponse> = null; let res: Maybe<IPosOrderResponse> = null;
return this.service.submitOrder(orderPayload).pipe( return this.service.submitOrder(orderPayload).pipe(
@@ -1,4 +1,5 @@
<div class="w-full min-h-[60svh] p-4 md:p-6"> @defer {
<div class="w-full min-h-[60svh] p-4 md:p-6">
<section class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-surface-200 bg-white"> <section class="mx-auto max-w-3xl overflow-hidden rounded-2xl border border-surface-200 bg-white">
<div class="bg-gradient-to-l from-sky-50 via-cyan-50 to-teal-50 p-5 md:p-7"> <div class="bg-gradient-to-l from-sky-50 via-cyan-50 to-teal-50 p-5 md:p-7">
<span class="block mb-4 text-xl font-bold text-surface-900 md:text-3xl">پشتیبانی {{ brandingInfo.title }}</span> <span class="block mb-4 text-xl font-bold text-surface-900 md:text-3xl">پشتیبانی {{ brandingInfo.title }}</span>
@@ -46,4 +47,5 @@
</p> </p>
</div> </div>
</section> </section>
</div> </div>
}
+2 -2
View File
@@ -1,8 +1,8 @@
// TIS tenant environment configuration // TIS tenant environment configuration
export const environment = { export const environment = {
production: true, production: true,
// apiBaseUrl: 'https://psp-api.shift-am.ir', apiBaseUrl: 'https://psp-api.shift-am.ir',
apiBaseUrl: 'http://192.168.128.73:5002', // apiBaseUrl: 'http://192.168.128.73:5002',
host: 'localhost', host: 'localhost',
port: 5000, port: 5000,
enableLogging: false, enableLogging: false,