Commit 5f90212d authored by Rais Aryaguna's avatar Rais Aryaguna

feat: Implement Java API integration for PPh 21 calculations and add helper...

feat: Implement Java API integration for PPh 21 calculations and add helper functions for Bupot 21 processing
parent 84c0012d
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import invariant from 'tiny-invariant';
import packageJson from '../package.json'; import packageJson from '../package.json';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function isValidUrl(url: string): boolean {
try {
new URL(url); // The `URL` constructor throws if the string is not a valid URL.
return true;
} catch {
return false;
}
}
invariant(
import.meta.env.VITE_HOST_API && isValidUrl(import.meta.env.VITE_HOST_API),
'VITE_HOST_API env is not exist or invalid'
);
invariant(
import.meta.env.VITE_HOST_API_JAVA && isValidUrl(import.meta.env.VITE_HOST_API_JAVA),
'VITE_HOST_API_JAVA env is not exist or invalid'
);
invariant(process.env.VITE_PORT_JAVA_PPH21, 'VITE_PORT_JAVA_PPH21 env is not exist or invalid');
invariant(process.env.VITE_PORT_JAVA_CETAK, 'VITE_PORT_JAVA_CETAK env is not exist or invalid');
invariant(process.env.VITE_USERNAME_JAVA, 'VITE_USERNAME_JAVA env is not exist or invalid');
invariant(process.env.VITE_PASSWORD_JAVA, 'VITE_PASSWORD_JAVA env is not exist or invalid');
// ----------------------------------------------------------------------
export type ConfigValue = { export type ConfigValue = {
appName: string; appName: string;
appVersion: string; appVersion: string;
...@@ -27,7 +55,11 @@ export type ConfigValue = { ...@@ -27,7 +55,11 @@ export type ConfigValue = {
auth0: { clientId: string; domain: string; callbackUrl: string }; auth0: { clientId: string; domain: string; callbackUrl: string };
supabase: { url: string; key: string }; supabase: { url: string; key: string };
nodesandbox: { api: string }; nodesandbox: { api: string };
apiJava: { api: string }; apiJava: {
api: string;
prot: { pph21: string; cetak: string };
AuthBesic: { username: string; password: string };
};
}; };
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
...@@ -92,6 +124,14 @@ export const CONFIG: ConfigValue = { ...@@ -92,6 +124,14 @@ export const CONFIG: ConfigValue = {
* java * java
*/ */
apiJava: { apiJava: {
api: import.meta.env.VITE_HOST_API ?? '', api: import.meta.env.VITE_HOST_API_JAVA ?? '',
prot: {
pph21: import.meta.env.VITE_PORT_JAVA_PPH21 ?? '',
cetak: import.meta.env.VITE_PORT_JAVA_CETAK ?? '',
},
AuthBesic: {
username: import.meta.env.VITE_USERNAME_JAVA ?? '',
password: import.meta.env.VITE_PASSWORD_JAVA ?? '',
},
}, },
}; };
...@@ -8,9 +8,25 @@ const API_CONFIGS = { ...@@ -8,9 +8,25 @@ const API_CONFIGS = {
baseURL: CONFIG.nodesandbox.api, // misal: https://api.yourdomain.com baseURL: CONFIG.nodesandbox.api, // misal: https://api.yourdomain.com
name: 'nodesandbox API', name: 'nodesandbox API',
}, },
apiJava: {
baseURL: CONFIG.apiJava.api,
portPPH21: CONFIG.apiJava.prot.pph21,
portCetak: CONFIG.apiJava.prot.cetak,
AuthUser: CONFIG.apiJava.AuthBesic.username,
AuthPass: CONFIG.apiJava.AuthBesic.password,
name: 'java API',
},
} as const; } as const;
const createAxiosInstance = (baseURL: string, name: string): AxiosInstance => { const createAxiosInstance = (
baseURL: string,
name: string,
options?: {
useBasicAuth?: boolean;
username?: string;
password?: string;
}
): AxiosInstance => {
const instance = axios.create({ const instance = axios.create({
baseURL, baseURL,
headers: { headers: {
...@@ -18,19 +34,26 @@ const createAxiosInstance = (baseURL: string, name: string): AxiosInstance => { ...@@ -18,19 +34,26 @@ const createAxiosInstance = (baseURL: string, name: string): AxiosInstance => {
}, },
}); });
// Request Interceptor - Tambahkan token jika diperlukan // Request Interceptor
instance.interceptors.request.use( instance.interceptors.request.use(
(config) => { (config) => {
// Basic Auth untuk Java API
if (options?.useBasicAuth && options.username && options.password) {
const credentials = btoa(`${options.username}:${options.password}`);
config.headers.Authorization = `Basic ${credentials}`;
} else {
// Bearer Token untuk Node API
const accessToken = localStorage.getItem(JWT_STORAGE_KEY); const accessToken = localStorage.getItem(JWT_STORAGE_KEY);
if (accessToken) { if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
} }
}
return config; return config;
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
); );
// Response Interceptor - Handle errors // Response Interceptor
instance.interceptors.response.use( instance.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
...@@ -48,6 +71,16 @@ export const axiosnodesandbox = createAxiosInstance( ...@@ -48,6 +71,16 @@ export const axiosnodesandbox = createAxiosInstance(
API_CONFIGS.nodesandbox.name API_CONFIGS.nodesandbox.name
); );
export const axiosApiJava = createAxiosInstance(
API_CONFIGS.apiJava.baseURL,
API_CONFIGS.apiJava.name,
{
useBasicAuth: true,
username: API_CONFIGS.apiJava.AuthUser,
password: API_CONFIGS.apiJava.AuthPass,
}
);
export default axiosnodesandbox; export default axiosnodesandbox;
type FetcherArgs = string | [string, AxiosRequestConfig & { method?: string }]; type FetcherArgs = string | [string, AxiosRequestConfig & { method?: string }];
...@@ -70,6 +103,24 @@ export const fetcher = async <T = unknown>(args: FetcherArgs): Promise<T> => { ...@@ -70,6 +103,24 @@ export const fetcher = async <T = unknown>(args: FetcherArgs): Promise<T> => {
} }
}; };
export const fetcherJava = async <T = unknown>(args: FetcherArgs): Promise<T> => {
try {
const [url, config = {}] = Array.isArray(args) ? args : [args, {}];
const { method = 'GET', ...restConfig } = config;
const res = await axiosApiJava.request<T>({
url,
method,
...restConfig,
});
return res.data;
} catch (error) {
console.error('[Java API] Fetcher failed:', error);
throw error;
}
};
export const endpoints = { export const endpoints = {
pph21: { pph21: {
bulanan: { bulanan: {
...@@ -92,4 +143,14 @@ export const endpoints = { ...@@ -92,4 +143,14 @@ export const endpoints = {
faktur_keterangan: '/sandbox/mst_faktur_keterangan', faktur_keterangan: '/sandbox/mst_faktur_keterangan',
faktur_idtambahan: '/sandbox/mst_faktur_idtambahan', faktur_idtambahan: '/sandbox/mst_faktur_idtambahan',
}, },
hitung: {
bulanan: '/pph21/v1/hitung/ctas/bulanan',
harian: '/pph21/v1/hitung/ctas/harian',
pasal17: '/pph21/v1/hitung/ctas/pasal17',
pesangon: '/pph21/v1/hitung/ctas/pesangon',
pensiun: '/pph21/v1/hitung/ctas/pensiun',
finalTdkFinal: '/bupot21/hitung/final',
tahunan: '/pph21/v1/hitung/ctas/yearly',
tahunanA2: 'IF_TXR_055/a2',
},
} as const; } as const;
import {
FG_PERHITUNGAN,
JENIS_PERHITUNGAN,
KODE_OBJEK_PAJAK,
PERHITUNGAN_BUPOT21,
} from './bupot-bulanan/constant';
import type { paramsHitung } from './type';
export const checkPerhitunganBupot21 = ({
kodeObjekPajak,
fgPerhitungan,
jenisPerhitungan,
phBruto,
}: paramsHitung) => {
switch (kodeObjekPajak) {
case KODE_OBJEK_PAJAK.BULANAN_01:
case KODE_OBJEK_PAJAK.BULANAN_02:
case KODE_OBJEK_PAJAK.BULANAN_03:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_10:
console.log('fgPerhitungan', fgPerhitungan, kodeObjekPajak, jenisPerhitungan, phBruto);
if (fgPerhitungan == FG_PERHITUNGAN.GROSS) return PERHITUNGAN_BUPOT21.BULANAN_GROSS;
if (fgPerhitungan == FG_PERHITUNGAN.GROSS_UP) return PERHITUNGAN_BUPOT21.BULANAN_GROSS_UP;
if (fgPerhitungan == FG_PERHITUNGAN.MIXED) return PERHITUNGAN_BUPOT21.BULANAN_MIXED;
return null;
case KODE_OBJEK_PAJAK.TIDAK_FINAL_03:
if (jenisPerhitungan == JENIS_PERHITUNGAN.BULANAN) {
if (fgPerhitungan == FG_PERHITUNGAN.GROSS) return PERHITUNGAN_BUPOT21.BULANAN_GROSS;
if (fgPerhitungan == FG_PERHITUNGAN.GROSS_UP) return PERHITUNGAN_BUPOT21.BULANAN_GROSS_UP;
if (fgPerhitungan == FG_PERHITUNGAN.MIXED) return PERHITUNGAN_BUPOT21.BULANAN_MIXED;
}
if (jenisPerhitungan == JENIS_PERHITUNGAN.HARIAN) {
if (phBruto <= 2500000) return PERHITUNGAN_BUPOT21.HARIAN;
if (phBruto > 2500000) return PERHITUNGAN_BUPOT21.PASAL17;
}
return null;
case KODE_OBJEK_PAJAK.TIDAK_FINAL_04:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_05:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_06:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_07:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_09:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_11:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_12:
case KODE_OBJEK_PAJAK.TIDAK_FINAL_13:
return PERHITUNGAN_BUPOT21.PASAL17;
case KODE_OBJEK_PAJAK.FINAL_01:
case KODE_OBJEK_PAJAK.FINAL_02:
return PERHITUNGAN_BUPOT21.FINAL;
default:
return null;
}
};
import {
useMutation,
type UseMutationResult,
type UseMutationOptions,
} from '@tanstack/react-query';
import type { AxiosError } from 'axios';
import { fetcherJava, endpoints } from 'src/lib/axios-ctas-box';
import type { transformParamsBupotBulananProps } from './type';
import { checkPerhitunganBupot21 } from './helper';
import { PERHITUNGAN_BUPOT21 } from './bupot-bulanan/constant';
// ========================================
// Types
// ========================================
interface ApiResponse<T> {
code: number;
status: string;
message?: string;
data: T;
}
interface ApiResponseBulanan {
nip: string | null;
tglBupot: string | null;
metode: string | null;
status: 'TK' | 'K' | 'HB';
nTanggungan: 0 | 1 | 2 | 3;
tunjanganPPh: string;
penghasilanBruto: string | null;
tarif: string;
kelasTer: string;
pphBulan: string;
pph21ditanggungperusahaan: string;
pph21ditanggungkaryawan: string;
pph21: string;
pphDipotong: string;
}
interface TransformedBupotParams {
status: string;
nTanggungan: string;
metode: 'gross' | 'gross-up';
tglBupot: string;
penghasilanKotor: string | number;
dpp: string | number;
}
type MutationProps = Omit<
UseMutationOptions<ApiResponseBulanan, AxiosError, transformParamsBupotBulananProps>,
'mutationKey' | 'mutationFn'
>;
// ========================================
// Validation & Transform Functions
// ========================================
const validateResponse = <T>(response: ApiResponse<T>): T => {
const failedStatuses = ['fail', 'error', '0'];
if (failedStatuses.includes(response.status)) {
throw new Error(
response.message || 'System tidak dapat memenuhi permintaan, coba beberapa saat lagi'
);
}
if (!response.data) {
throw new Error('Data response tidak ditemukan');
}
return response.data;
};
const transformParamsBupotBulanan = (
params: transformParamsBupotBulananProps
): TransformedBupotParams => {
const [status, nTanggungan] = params.status.split('/');
if (!status || !nTanggungan) {
throw new Error('Format status tidak valid. Expected: "STATUS/N_TANGGUNGAN"');
}
return {
status,
nTanggungan,
metode: params.metode,
tglBupot: params.tglBupot,
penghasilanKotor: params.penghasilanKotor,
dpp: params.dppPersen,
};
};
const normalizeResponse = (data: ApiResponseBulanan): ApiResponseBulanan => ({
...data,
pph21: data.pph21 || data.pphDipotong || '0',
});
// ========================================
// API Request Handlers
// ========================================
const { bulanan, harian, pasal17, pensiun, pesangon, tahunan, tahunanA2 } = endpoints.hitung;
const hitungBulananGross = async (
params: transformParamsBupotBulananProps
): Promise<ApiResponseBulanan> => {
const response = await fetcherJava<ApiResponse<ApiResponseBulanan>>([
bulanan,
{
method: 'POST',
data: transformParamsBupotBulanan({ ...params, metode: 'gross' }),
},
]);
return normalizeResponse(validateResponse(response));
};
const hitungBulananGrossUp = async (
params: transformParamsBupotBulananProps
): Promise<ApiResponseBulanan> => {
const response = await fetcherJava<ApiResponse<ApiResponseBulanan>>([
bulanan,
{
method: 'POST',
data: transformParamsBupotBulanan({ ...params, metode: 'gross-up' }),
},
]);
return normalizeResponse(validateResponse(response));
};
const handleHitungBulanan = async (
params: transformParamsBupotBulananProps
): Promise<ApiResponseBulanan> => {
const checkPerhitungan = checkPerhitunganBupot21(params);
switch (checkPerhitungan) {
case PERHITUNGAN_BUPOT21.BULANAN_GROSS:
return hitungBulananGross(params);
case PERHITUNGAN_BUPOT21.BULANAN_GROSS_UP:
return hitungBulananGrossUp(params);
default:
throw new Error(
`Tipe perhitungan tidak valid: ${checkPerhitungan}. Expected: ${PERHITUNGAN_BUPOT21.BULANAN_GROSS} atau ${PERHITUNGAN_BUPOT21.BULANAN_GROSS_UP}`
);
}
};
// ========================================
// React Query Hook
// ========================================
/**
* Hook untuk hitung PPh 21 Bulanan
*
* @example
* ```tsx
* const { mutate, isPending, error } = useHitungBulanan({
* onSuccess: (data) => {
* console.log('PPh21:', data.pph21);
* },
* onError: (error) => {
* toast.error(error.message);
* }
* });
*
* mutate({
* status: 'TK/0',
* tglBupot: '2025-01-15',
* penghasilanKotor: 5000000,
* dppPersen: 5,
* metode: 'gross'
* });
* ```
*/
export function useHitungBulanan(
props?: MutationProps
): UseMutationResult<ApiResponseBulanan, AxiosError, transformParamsBupotBulananProps> {
return useMutation<ApiResponseBulanan, AxiosError, transformParamsBupotBulananProps>({
mutationKey: ['pph-21-26', 'bulanan', 'hitung'],
mutationFn: handleHitungBulanan,
...props,
});
}
// ========================================
// Error Handler Utility (Optional)
// ========================================
/**
* Helper untuk handle error dari mutation
*/
export const getHitungBulananErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'object' && error !== null && 'response' in error) {
const axiosError = error as AxiosError<ApiResponse<any>>;
return (
axiosError.response?.data?.message ||
axiosError.message ||
'Terjadi kesalahan saat menghitung PPh 21'
);
}
return 'Terjadi kesalahan yang tidak diketahui';
};
export type paramsHitung = {
kodeObjekPajak: string;
fgPerhitungan: string;
jenisPerhitungan: string;
phBruto: number;
};
export type transformParamsBupotBulananProps = {
status: string;
metode: 'gross' | 'gross-up';
tglBupot: string;
penghasilanKotor: string;
dppPersen: string;
} & paramsHitung;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment