mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into handover
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
FileInput,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Send,
|
Send,
|
||||||
|
Upload,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -36,15 +38,17 @@ import {
|
|||||||
contractFormSchema,
|
contractFormSchema,
|
||||||
contractStepFields,
|
contractStepFields,
|
||||||
initialContractFormValues,
|
initialContractFormValues,
|
||||||
|
OPERATION_TYPES,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
type OperationType,
|
type OperationType,
|
||||||
} from "./new-contract-form/schema";
|
} from "./new-contract-form/schema";
|
||||||
import {
|
import {
|
||||||
allowedOperationsForProfiles,
|
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
} from "./new-contract-form/helpers";
|
} from "./new-contract-form/helpers";
|
||||||
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
|
import type { ProfileTypeValue } from "@/services/companies.service";
|
||||||
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
import { StepIndicator } from "./new-contract-form/StepIndicator";
|
||||||
import {
|
import {
|
||||||
clearContractDraft,
|
clearContractDraft,
|
||||||
@@ -240,19 +244,92 @@ export default function NewContractPage() {
|
|||||||
[auth.company],
|
[auth.company],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// All operation types are always selectable. Picking one the company has no
|
||||||
|
// profile for prompts a license upload that creates the profile on the fly
|
||||||
|
// (mirrors the header "Add service" flow).
|
||||||
const allowedOperations = useMemo<OperationType[]>(
|
const allowedOperations = useMemo<OperationType[]>(
|
||||||
() => allowedOperationsForProfiles(profileTypes),
|
() => [...OPERATION_TYPES],
|
||||||
[profileTypes],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Create-profile modal state (license upload → createProfileAndSwitch).
|
||||||
|
const [createTarget, setCreateTarget] = useState<ProfileTypeValue | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [pendingOperation, setPendingOperation] =
|
||||||
|
useState<OperationType | null>(null);
|
||||||
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createProfileMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
type,
|
||||||
|
files,
|
||||||
|
}: {
|
||||||
|
type: ProfileTypeValue;
|
||||||
|
files: File[];
|
||||||
|
}) => {
|
||||||
|
const res = await auth.createProfileAndSwitch(type, files);
|
||||||
|
if (!res.success) {
|
||||||
|
throw new Error(res.error?.message ?? "Failed to create profile");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// The operation was already set on the form when the modal opened.
|
||||||
|
setCreateTarget(null);
|
||||||
|
setPendingOperation(null);
|
||||||
|
setLicenseFiles([]);
|
||||||
|
setCreateError(null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setCreateError(
|
||||||
|
err instanceof Error ? err.message : "Failed to create profile",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleOperationSelect = (op: OperationType) => {
|
const handleOperationSelect = (op: OperationType) => {
|
||||||
|
// Intercity (domestic) runs on any existing customer profile — no switch.
|
||||||
if (op === "intercity") return;
|
if (op === "intercity") return;
|
||||||
const target = operationToProfileType(op, profileTypes);
|
const target = operationToProfileType(op, profileTypes) as ProfileTypeValue;
|
||||||
|
const hasProfile = profileTypes.includes(target);
|
||||||
|
if (!hasProfile) {
|
||||||
|
// No matching profile — collect a license and create one.
|
||||||
|
setPendingOperation(op);
|
||||||
|
setCreateTarget(target);
|
||||||
|
setLicenseFiles([]);
|
||||||
|
setCreateError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (auth.activeProfileType !== target) {
|
if (auth.activeProfileType !== target) {
|
||||||
void auth.switchMode(target as never);
|
void auth.switchMode(target as never);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateProfileConfirm = () => {
|
||||||
|
if (!createTarget) return;
|
||||||
|
if (licenseFiles.length === 0) {
|
||||||
|
setCreateError("Please upload at least one business license file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createProfileMutation.mutate({ type: createTarget, files: licenseFiles });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProfileCancel = () => {
|
||||||
|
// Roll back the operation selection that triggered the modal.
|
||||||
|
if (pendingOperation) {
|
||||||
|
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||||
|
}
|
||||||
|
setCreateTarget(null);
|
||||||
|
setPendingOperation(null);
|
||||||
|
setLicenseFiles([]);
|
||||||
|
setCreateError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTargetLabel = createTarget
|
||||||
|
? (PROFILE_TYPE_LABELS[createTarget] ?? createTarget)
|
||||||
|
: "";
|
||||||
|
|
||||||
const onboardingDocs = useMemo(() => {
|
const onboardingDocs = useMemo(() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
const active =
|
const active =
|
||||||
@@ -811,6 +888,54 @@ export default function NewContractPage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Create-profile modal — opens when the chosen operation type has no
|
||||||
|
matching company profile yet. Collects a license, creates the profile,
|
||||||
|
and switches to it (mirrors the header "Add service" flow). */}
|
||||||
|
<Modal
|
||||||
|
opened={createTarget !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!createProfileMutation.isPending) handleCreateProfileCancel();
|
||||||
|
}}
|
||||||
|
title={`Set up your ${createTargetLabel} profile`}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
You don't have a {createTargetLabel.toLowerCase()} profile yet. Add
|
||||||
|
your business license to create one and continue this contract as{" "}
|
||||||
|
{createTargetLabel.toLowerCase()}.
|
||||||
|
</Text>
|
||||||
|
<FileInput
|
||||||
|
label="Business license"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
accept="application/pdf,image/png,image/jpeg"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
placeholder="Select license file(s)"
|
||||||
|
value={licenseFiles}
|
||||||
|
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||||
|
error={createError ?? undefined}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleCreateProfileCancel}
|
||||||
|
disabled={createProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
onClick={handleCreateProfileConfirm}
|
||||||
|
loading={createProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
Create & continue
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
FileCheck2,
|
FileCheck2,
|
||||||
FileText,
|
FileText,
|
||||||
Info,
|
Info,
|
||||||
PackageCheck,
|
// PackageCheck,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Truck,
|
Truck,
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ export function Step3CargoScope({
|
|||||||
label={`${size} cap (containers)`}
|
label={`${size} cap (containers)`}
|
||||||
placeholder="0 = unlimited"
|
placeholder="0 = unlimited"
|
||||||
min={0}
|
min={0}
|
||||||
value={field.value ?? 0}
|
value={Number(field.value ?? 0)}
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
@@ -258,7 +258,7 @@ export function Step3CargoScope({
|
|||||||
label="Total cap (tons / items)"
|
label="Total cap (tons / items)"
|
||||||
placeholder="0 = unlimited"
|
placeholder="0 = unlimited"
|
||||||
min={0}
|
min={0}
|
||||||
value={field.value ?? 0}
|
value={Number(field.value ?? 0)}
|
||||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||||
radius={10}
|
radius={10}
|
||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ CREATE TABLE "system_features" (
|
|||||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"config" JSONB,
|
"config" JSONB,
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,69 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minor-unit decimal places per currency, used to round the CHARGE amount sent to the payment
|
||||||
|
* microservice. DJF has no minor unit (whole francs only); ETB and USD use 2 decimals.
|
||||||
|
*/
|
||||||
|
const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
|
||||||
|
ETB: 2,
|
||||||
|
USD: 2,
|
||||||
|
DJF: 0,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrencyService {
|
export class CurrencyService {
|
||||||
private readonly logger = new Logger(CurrencyService.name);
|
private readonly logger = new Logger(CurrencyService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async convertEtbMinorToChargeMajor(
|
||||||
|
amountMinorEtb: number,
|
||||||
|
targetCurrency: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const target = targetCurrency.toUpperCase();
|
||||||
|
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||||
|
if (decimals === undefined) {
|
||||||
|
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMajor = amountMinorEtb / 100;
|
||||||
|
if (target === Currency.ETB) {
|
||||||
|
return this.roundTo(sourceMajor, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||||
|
return this.roundTo(sourceMajor * rate, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRateOrThrow(
|
||||||
|
fromCurrency: Currency,
|
||||||
|
toCurrency: Currency,
|
||||||
|
): Promise<number> {
|
||||||
|
if (fromCurrency === toCurrency) return 1;
|
||||||
|
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
|
where: { fromCurrency, toCurrency },
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
if (!exchangeRate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(exchangeRate.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private roundTo(value: number, decimals: number): number {
|
||||||
|
const factor = 10 ** decimals;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
async convertAmount(
|
async convertAmount(
|
||||||
amountMinor: number,
|
amountMinor: number,
|
||||||
fromCurrency: Currency,
|
fromCurrency: Currency,
|
||||||
|
|||||||
@@ -115,25 +115,35 @@ export class PassengersService {
|
|||||||
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
||||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||||
|
|
||||||
|
// Parse notes JSON to extract phone and other data
|
||||||
|
let notesData: any = null;
|
||||||
|
if (profile.notes) {
|
||||||
|
try {
|
||||||
|
notesData = typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes;
|
||||||
|
} catch {
|
||||||
|
notesData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
fullName: profile.fullName,
|
fullName: profile.fullName,
|
||||||
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
|
email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null,
|
||||||
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
|
phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null,
|
||||||
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||||
dateOfBirth: profile.dateOfBirth
|
dateOfBirth: profile.dateOfBirth
|
||||||
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
||||||
: (localUser?.dateOfBirth
|
: (localUser?.dateOfBirth
|
||||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||||
: iam?.metadata?.dateOfBirth ?? null),
|
: iam?.metadata?.dateOfBirth ?? null),
|
||||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
||||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||||
faydaVerified,
|
faydaVerified,
|
||||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
||||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||||
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
|
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null),
|
||||||
verified: faydaVerified,
|
verified: faydaVerified,
|
||||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||||
role: localUser?.role ?? null,
|
role: localUser?.role ?? null,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
|
|||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { SeatsModule } from "../seats/seats.module";
|
import { SeatsModule } from "../seats/seats.module";
|
||||||
import { TicketsModule } from "../tickets/tickets.module";
|
import { TicketsModule } from "../tickets/tickets.module";
|
||||||
|
import { CurrencyModule } from "../currency/currency.module";
|
||||||
|
|
||||||
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
const PASSENGER_QUEUE = PAYMENT_QUEUES[PaymentService.PASSENGER];
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
imports: [
|
imports: [
|
||||||
SeatsModule,
|
SeatsModule,
|
||||||
TicketsModule,
|
TicketsModule,
|
||||||
|
CurrencyModule,
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
...rabbitMQImport(),
|
...rabbitMQImport(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PaymentsService } from "./payments.service";
|
import { PaymentsService } from "./payments.service";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
|
import { CurrencyService } from "../currency/currency.service";
|
||||||
import { PrismaService } from "../../common/prisma.service";
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
import { SeatsService } from "../seats/seats.service";
|
import { SeatsService } from "../seats/seats.service";
|
||||||
import { TicketsService } from "../tickets/tickets.service";
|
import { TicketsService } from "../tickets/tickets.service";
|
||||||
@@ -34,6 +35,9 @@ describe("PaymentsService", () => {
|
|||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
},
|
},
|
||||||
|
paymentMethod: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
walletAccount: {
|
walletAccount: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
@@ -69,6 +73,14 @@ describe("PaymentsService", () => {
|
|||||||
getIntentByReference: jest.fn(),
|
getIntentByReference: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirrors the real ETB→major conversion: minor units → major price (TELEBIRR settles in ETB).
|
||||||
|
const mockCurrencyService = {
|
||||||
|
convertEtbMinorToChargeMajor: jest.fn((minor: number) =>
|
||||||
|
Promise.resolve(minor / 100),
|
||||||
|
),
|
||||||
|
getRateOrThrow: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const requiresActionSnapshot = (
|
const requiresActionSnapshot = (
|
||||||
provider: ProviderMethod,
|
provider: ProviderMethod,
|
||||||
): PaymentIntentSnapshot => ({
|
): PaymentIntentSnapshot => ({
|
||||||
@@ -93,6 +105,7 @@ describe("PaymentsService", () => {
|
|||||||
{ provide: TicketsService, useValue: mockTicketsService },
|
{ provide: TicketsService, useValue: mockTicketsService },
|
||||||
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
{ provide: EventEmitter2, useValue: mockEventEmitter },
|
||||||
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
{ provide: PaymentClientService, useValue: mockPaymentClient },
|
||||||
|
{ provide: CurrencyService, useValue: mockCurrencyService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -167,7 +180,8 @@ describe("PaymentsService", () => {
|
|||||||
referenceType: PaymentReferenceType.BOOKING,
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
referenceId: "booking-1",
|
referenceId: "booking-1",
|
||||||
orderRef: "EDR123456",
|
orderRef: "EDR123456",
|
||||||
amountMinor: 50000,
|
// 50000 minor ETB → 500.00 major, settled in ETB (no FX for Ethiopian methods).
|
||||||
|
amountMinor: 500,
|
||||||
currency: "ETB",
|
currency: "ETB",
|
||||||
provider: "TELEBIRR",
|
provider: "TELEBIRR",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
|
import { CurrencyService } from "../currency/currency.service";
|
||||||
import {
|
import {
|
||||||
PaymentService as PaymentServiceEnum,
|
PaymentService as PaymentServiceEnum,
|
||||||
PaymentReferenceType,
|
PaymentReferenceType,
|
||||||
@@ -42,7 +43,6 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
private readonly logger = new Logger(PaymentsService.name);
|
private readonly logger = new Logger(PaymentsService.name);
|
||||||
private readonly walletDemoAutoSucceed = true;
|
|
||||||
|
|
||||||
private readonly waafiDemoTrustReturn = true;
|
private readonly waafiDemoTrustReturn = true;
|
||||||
|
|
||||||
@@ -52,6 +52,7 @@ export class PaymentsService {
|
|||||||
private ticketsService: TicketsService,
|
private ticketsService: TicketsService,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
private paymentClient: PaymentClientService,
|
private paymentClient: PaymentClientService,
|
||||||
|
private currencyService: CurrencyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getAll(filters: {
|
async getAll(filters: {
|
||||||
@@ -132,15 +133,28 @@ export class PaymentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
const { returnUrl, failureUrl } = this.resolveReturnUrls(method);
|
||||||
|
|
||||||
|
// The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
|
||||||
|
// settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into
|
||||||
|
// that currency here so the payment microservice stays currency-agnostic and charges it as-is.
|
||||||
|
const paymentMethod = await this.prisma.paymentMethod.findUnique({
|
||||||
|
where: { type: method },
|
||||||
|
});
|
||||||
|
const chargeCurrency = (
|
||||||
|
paymentMethod?.currency ?? booking.currency
|
||||||
|
).toUpperCase();
|
||||||
|
const chargeAmount = await this.currencyService.convertEtbMinorToChargeMajor(
|
||||||
|
booking.totalMinor,
|
||||||
|
chargeCurrency,
|
||||||
|
);
|
||||||
|
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.PASSENGER,
|
service: PaymentServiceEnum.PASSENGER,
|
||||||
referenceType: PaymentReferenceType.BOOKING,
|
referenceType: PaymentReferenceType.BOOKING,
|
||||||
referenceId: booking.id,
|
referenceId: booking.id,
|
||||||
orderRef: booking.bookingRef,
|
orderRef: booking.bookingRef,
|
||||||
// Send the REAL (major) price, not minor units. The payment API no longer divides by 100
|
amountMinor: chargeAmount,
|
||||||
// (freight already passes the real price), so the providers charge this value as-is.
|
currency: chargeCurrency,
|
||||||
amountMinor: booking.totalMinor / 100,
|
|
||||||
currency: booking.currency,
|
|
||||||
provider: method as unknown as ProviderMethod,
|
provider: method as unknown as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: dto.platform,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
@@ -266,35 +280,6 @@ export class PaymentsService {
|
|||||||
private async initiateWalletPayment(
|
private async initiateWalletPayment(
|
||||||
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
|
||||||
): Promise<InitiateResponseDto> {
|
): Promise<InitiateResponseDto> {
|
||||||
// DEMO ONLY (WALLET_DEMO_AUTO_SUCCEED): pretend the payment succeeded — no balance check,
|
|
||||||
// no debit — and run the exact same finalize path a real successful payment uses
|
|
||||||
// (booking → CONFIRMED, seats confirmed, ticket issued). Remove once a real provider works.
|
|
||||||
if (this.walletDemoAutoSucceed) {
|
|
||||||
this.logger.warn(
|
|
||||||
`WALLET_DEMO_AUTO_SUCCEED enabled — faking a successful WALLET payment for booking ${booking.bookingRef} (${booking.id})`,
|
|
||||||
);
|
|
||||||
const demoIntent = await this.prisma.paymentIntent.upsert({
|
|
||||||
where: { bookingId: booking.id },
|
|
||||||
update: {
|
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
|
||||||
failureCode: null,
|
|
||||||
method: PaymentMethodType.WALLET,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
bookingId: booking.id,
|
|
||||||
amountMinor: booking.totalMinor,
|
|
||||||
method: PaymentMethodType.WALLET,
|
|
||||||
status: PaymentIntentStatus.PROCESSING,
|
|
||||||
providerRef: `WALLET-DEMO-${Date.now()}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.finalizePaymentSuccess({ intentId: demoIntent.id });
|
|
||||||
const settled = await this.prisma.paymentIntent.findUniqueOrThrow({
|
|
||||||
where: { id: demoIntent.id },
|
|
||||||
});
|
|
||||||
return this.formatIntentResponse(settled);
|
|
||||||
}
|
|
||||||
|
|
||||||
const debitResult = await this.prisma.$transaction(async (tx) => {
|
const debitResult = await this.prisma.$transaction(async (tx) => {
|
||||||
const wallet = await tx.walletAccount.findUnique({
|
const wallet = await tx.walletAccount.findUnique({
|
||||||
where: { passengerId: booking.passengerId },
|
where: { passengerId: booking.passengerId },
|
||||||
@@ -661,21 +646,6 @@ export class PaymentsService {
|
|||||||
return { processed: false, reason: "booking-not-found" };
|
return { processed: false, reason: "booking-not-found" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The event carries the REAL (major) price the provider charged (passenger now sends
|
|
||||||
// booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
|
|
||||||
// with booking.totalMinor (which is in minor units).
|
|
||||||
const eventAmountMinor = Math.round(event.amountMinor * 100);
|
|
||||||
if (booking.totalMinor !== eventAmountMinor) {
|
|
||||||
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
|
||||||
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
|
||||||
this.logger.error(
|
|
||||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException(
|
|
||||||
"Event amount does not match booking total",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local intent row is a projection during the strangler migration: reuse it when the
|
// Local intent row is a projection during the strangler migration: reuse it when the
|
||||||
// legacy initiate path created one, otherwise materialize it from the event.
|
// legacy initiate path created one, otherwise materialize it from the event.
|
||||||
let intent = await this.prisma.paymentIntent.findUnique({
|
let intent = await this.prisma.paymentIntent.findUnique({
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ export class TicketsService {
|
|||||||
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// if (filters.coachId) {
|
if (filters.coachId) {
|
||||||
// where.seat = {
|
where.seat = {
|
||||||
// coachId: filters.coachId
|
coachId: filters.coachId
|
||||||
// };
|
};
|
||||||
// }
|
}
|
||||||
|
|
||||||
const [tickets, total] = await Promise.all([
|
const [tickets, total] = await Promise.all([
|
||||||
this.prisma.ticket.findMany({
|
this.prisma.ticket.findMany({
|
||||||
@@ -67,8 +67,8 @@ export class TicketsService {
|
|||||||
booking: {
|
booking: {
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||||
passenger: { select: { id: true, iamUserId: true } },
|
passenger: { include: { travelerProfiles: true } },
|
||||||
seats: { include: { seat: { include: { coach: true } } } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -93,9 +93,41 @@ export class TicketsService {
|
|||||||
return {
|
return {
|
||||||
items: tickets.map((t: any) => {
|
items: tickets.map((t: any) => {
|
||||||
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||||
|
|
||||||
|
// Extract phone from TravelerProfile notes JSON
|
||||||
|
let guestPhone = null;
|
||||||
|
let guestEmail = null;
|
||||||
|
const matchingProfile = t.booking?.passenger?.travelerProfiles?.find((tp: any) => tp.fullName === t.passengerName);
|
||||||
|
|
||||||
|
// DEBUG: Log to see what we're getting
|
||||||
|
this.logger.debug(`Ticket ${t.id}: passengerName=${t.passengerName}, profiles count=${t.booking?.passenger?.travelerProfiles?.length || 0}, matchingProfile=${!!matchingProfile}`);
|
||||||
|
if (matchingProfile) {
|
||||||
|
this.logger.debug(`Matching profile notes: ${matchingProfile.notes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchingProfile?.notes) {
|
||||||
|
try {
|
||||||
|
const notesData = JSON.parse(matchingProfile.notes);
|
||||||
|
guestPhone = notesData.phone || null;
|
||||||
|
guestEmail = notesData.email || null;
|
||||||
|
this.logger.debug(`Extracted from notes: phone=${guestPhone}, email=${guestEmail}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to parse notes JSON: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to booking contact info if no match in TravelerProfile
|
||||||
|
if (!guestPhone) guestPhone = t.booking?.contactPhone;
|
||||||
|
if (!guestEmail) guestEmail = t.booking?.contactEmail;
|
||||||
|
|
||||||
|
this.logger.debug(`Final values: phone=${guestPhone}, email=${guestEmail}`);
|
||||||
|
|
||||||
const passengerInfo = iam
|
const passengerInfo = iam
|
||||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||||
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
|
: { fullName: 'Guest', email: guestEmail, phone: guestPhone };
|
||||||
|
|
||||||
|
this.logger.debug(`Final passenger info: ${JSON.stringify(passengerInfo)}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
ticketNumber: t.barcodePayload,
|
ticketNumber: t.barcodePayload,
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function BookingsPageContent() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contact', label: 'Contact',
|
key: 'contact', label: 'Primary contact',
|
||||||
render: (booking: any) => (
|
render: (booking: any) => (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
|
<div className="font-medium">{booking.contactPhone || booking.passenger?.phone}</div>
|
||||||
|
|||||||
@@ -348,14 +348,8 @@ export default function TicketsPage() {
|
|||||||
key: 'contact',
|
key: 'contact',
|
||||||
label: 'Contact',
|
label: 'Contact',
|
||||||
render: (ticket: any) => {
|
render: (ticket: any) => {
|
||||||
// Find the booking seat that matches this ticket's passenger
|
const phone = ticket.booking?.passenger?.phone || 'N/A';
|
||||||
const matchingSeat = ticket.booking?.seats?.find((s: any) =>
|
const email = ticket.booking?.passenger?.email || 'N/A';
|
||||||
s.passengerName === ticket.passengerName && s.leg === ticket.leg
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try to get phone from BookingSeat first, then fall back to booking contact
|
|
||||||
const phone = matchingSeat?.phone || ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || 'N/A';
|
|
||||||
const email = matchingSeat?.email || ticket.booking?.contactEmail || ticket.booking?.passenger?.email || 'N/A';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -372,16 +366,21 @@ export default function TicketsPage() {
|
|||||||
label: 'Trip',
|
label: 'Trip',
|
||||||
render: (ticket: any) => {
|
render: (ticket: any) => {
|
||||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
const returnArrivalAt = ticket.booking?.returnSchedule?.arrivalAt;
|
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}
|
{!isRoundTrip ? (
|
||||||
{isRoundTrip && (
|
<span>{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'}</span>
|
||||||
<span> → {returnArrivalAt ? formatDateTimeShort(returnArrivalAt) : 'N/A'}</span>
|
) : (
|
||||||
|
<span>
|
||||||
|
{ticket.schedule?.departureAt ? formatDateTimeShort(ticket.schedule.departureAt) : 'N/A'} ·
|
||||||
|
{returnDeparture ? formatDateTimeShort(returnDeparture) : 'N/A'}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,9 +424,26 @@ export default function TicketsPage() {
|
|||||||
{
|
{
|
||||||
key: 'arrivalDate',
|
key: 'arrivalDate',
|
||||||
label: 'Arrival Date',
|
label: 'Arrival Date',
|
||||||
render: (ticket: any) => (
|
render: (ticket: any) => {
|
||||||
<span className="text-sm">{ticket.schedule?.arrivalAt ? new Date(ticket.schedule.arrivalAt).toLocaleDateString() : '—'}</span>
|
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
),
|
const outboundArrival = ticket.schedule?.arrivalAt;
|
||||||
|
const returnArrival = ticket.booking?.returnSchedule?.arrivalAt;
|
||||||
|
|
||||||
|
if (!isRoundTrip) {
|
||||||
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
{outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 text-sm">
|
||||||
|
<span>➡ {outboundArrival ? new Date(outboundArrival).toLocaleDateString() : '—'}</span>
|
||||||
|
<span>⬅ {returnArrival ? new Date(returnArrival).toLocaleDateString() : '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'boardingTimes',
|
key: 'boardingTimes',
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ export default registerAs("waafi", () => ({
|
|||||||
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "",
|
webhookSecret: process.env.WAAFI_WEBHOOK_SECRET ?? "",
|
||||||
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
// Wallet payment method (EVC/ZAAD/Sahal) — MWALLET_ACCOUNT requires the payer phone up front.
|
||||||
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
|
paymentMethod: process.env.WAAFI_PAYMENT_METHOD ?? "MWALLET_ACCOUNT",
|
||||||
// Waafi has no ETB; when set this overrides the asserted currency (USD/DJF/SLSH).
|
// Currency is no longer overridden here — the calling app converts to the method's settlement
|
||||||
// TODO(demo): revert to the env-driven line below after the demo. Temporarily FORCED to USD
|
// currency and the provider charges that value verbatim.
|
||||||
// here so the .env (WAAFI_CURRENCY) cannot override it.
|
|
||||||
// currency: process.env.WAAFI_CURRENCY ?? "DJF",
|
|
||||||
currency: "USD",
|
|
||||||
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
// Browser redirect targets after the hosted page completes/fails (UX only; webhook is source of truth).
|
||||||
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
|
successUrl: process.env.WAAFI_HPP_SUCCESS_URL ?? "",
|
||||||
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
|
failureUrl: process.env.WAAFI_HPP_FAILURE_URL ?? "",
|
||||||
|
|||||||
@@ -210,7 +210,8 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
business_type: "OnlineMerchant" as const,
|
business_type: "OnlineMerchant" as const,
|
||||||
title: `${input.orderRef}`,
|
title: `${input.orderRef}`,
|
||||||
total_amount: totalAmount,
|
total_amount: totalAmount,
|
||||||
trans_currency: this.currency,
|
// Charge the currency the caller already converted to; never relabel it provider-side.
|
||||||
|
trans_currency: input.currency,
|
||||||
timeout_express: this.timeoutExpress,
|
timeout_express: this.timeoutExpress,
|
||||||
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
|
||||||
},
|
},
|
||||||
@@ -341,9 +342,6 @@ export class DMoneyProvider implements PaymentProvider {
|
|||||||
private get language(): string {
|
private get language(): string {
|
||||||
return this.config.get<string>("dmoney.language") ?? "en";
|
return this.config.get<string>("dmoney.language") ?? "en";
|
||||||
}
|
}
|
||||||
private get currency(): string {
|
|
||||||
return this.config.get<string>("dmoney.currency") ?? "FDJ";
|
|
||||||
}
|
|
||||||
private get privateKey(): string {
|
private get privateKey(): string {
|
||||||
return this.config.get<string>("dmoney.privateKey") ?? "";
|
return this.config.get<string>("dmoney.privateKey") ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,8 +208,9 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
transactionInfo: {
|
transactionInfo: {
|
||||||
referenceId: input.merchantOrderId,
|
referenceId: input.merchantOrderId,
|
||||||
amount: this.toAmount(input.amountMinor),
|
amount: this.toAmount(input.amountMinor),
|
||||||
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
|
// Charge exactly the currency the caller already converted to (passenger/freight resolve
|
||||||
currency: this.currency || input.currency,
|
// the method's settlement currency). The provider never relabels the currency.
|
||||||
|
currency: input.currency,
|
||||||
description: `${input.orderRef}`,
|
description: `${input.orderRef}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,9 +299,6 @@ export class WaafiProvider implements PaymentProvider {
|
|||||||
private get paymentMethod(): string {
|
private get paymentMethod(): string {
|
||||||
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
|
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
|
||||||
}
|
}
|
||||||
private get currency(): string {
|
|
||||||
return this.config.get<string>("waafi.currency") ?? "";
|
|
||||||
}
|
|
||||||
private get successUrl(): string {
|
private get successUrl(): string {
|
||||||
return this.config.get<string>("waafi.successUrl") ?? "";
|
return this.config.get<string>("waafi.successUrl") ?? "";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user