diff --git a/package.json b/package.json index 1afbdb23..c00756e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iSunFA", - "version": "0.8.2+15", + "version": "0.8.2+16", "private": false, "scripts": { "dev": "next dev", diff --git a/src/constants/api_connection.ts b/src/constants/api_connection.ts index cf0f63c2..dfddab9f 100644 --- a/src/constants/api_connection.ts +++ b/src/constants/api_connection.ts @@ -93,6 +93,8 @@ export enum APIName { PUBLIC_KEY_GET = 'PUBLIC_KEY_GET', ZOD_EXAMPLE = 'ZOD_EXAMPLE', // Info: (20240909 - Murky) This is a Zod example, to demonstrate how to use Zod schema to validate data. CERTIFICATE_LIST = 'CERTIFICATE_LIST', + TRIAL_BALANCE_LIST_V2 = 'TRIAL_BALANCE_LIST_V2', + LEDGER_LIST_V2 = 'LEDGER_LIST_V2', } export enum APIPath { @@ -165,6 +167,8 @@ export enum APIPath { PUBLIC_KEY_GET = `${apiPrefix}/company/:companyId/public_key`, ZOD_EXAMPLE = `${apiPrefix}/company/zod`, // Info: (20240909 - Murky) This is a Zod example, to demonstrate how to use Zod schema to validate data. CERTIFICATE_LIST = `${apiPrefix}/company/:companyId/certificate`, + TRIAL_BALANCE_LIST_V2 = `${apiPrefixV2}/company/:companyId/trial_balance`, + LEDGER_LIST_V2 = `${apiPrefixV2}/company/:companyId/ledger`, } const createConfig = ({ name, @@ -506,4 +510,14 @@ export const APIConfig: Record = { method: HttpMethod.GET, path: APIPath.CERTIFICATE_LIST, }), + [APIName.TRIAL_BALANCE_LIST_V2]: createConfig({ + name: APIName.TRIAL_BALANCE_LIST_V2, + method: HttpMethod.GET, + path: APIPath.TRIAL_BALANCE_LIST_V2, + }), + [APIName.LEDGER_LIST_V2]: createConfig({ + name: APIName.LEDGER_LIST_V2, + method: HttpMethod.GET, + path: APIPath.LEDGER_LIST_V2, + }), }; diff --git a/src/constants/zod_schema.ts b/src/constants/zod_schema.ts index 1d122596..c1cc8b3c 100644 --- a/src/constants/zod_schema.ts +++ b/src/constants/zod_schema.ts @@ -17,12 +17,14 @@ import { journalListValidator, } from '@/lib/utils/zod_schema/journal'; import { kycUploadValidator } from '@/lib/utils/zod_schema/kyc'; +import { ledgerListValidator } from '@/lib/utils/zod_schema/ledger'; import { ocrDeleteValidator, ocrListValidator, ocrResultGetByIdValidator, ocrUploadValidator, } from '@/lib/utils/zod_schema/ocr'; +import { trialBalanceListValidator } from '@/lib/utils/zod_schema/trial_balance'; import { voucherCreateValidator, voucherUpdateValidator } from '@/lib/utils/zod_schema/voucher'; import { zodExampleValidator } from '@/lib/utils/zod_schema/zod_example'; @@ -59,4 +61,7 @@ export const API_ZOD_SCHEMA = { [APIName.CERTIFICATE_POST_V2]: certificatePostValidator, [APIName.CERTIFICATE_PUT_V2]: certificatePutValidator, [APIName.CERTIFICATE_DELETE_V2]: certificateDeleteValidator, + + [APIName.TRIAL_BALANCE_LIST_V2]: trialBalanceListValidator, + [APIName.LEDGER_LIST_V2]: ledgerListValidator, }; diff --git a/src/interfaces/api_connection.ts b/src/interfaces/api_connection.ts index ae36fdae..7e6d4176 100644 --- a/src/interfaces/api_connection.ts +++ b/src/interfaces/api_connection.ts @@ -64,7 +64,9 @@ export type IAPIName = | 'GET_PROJECT_BY_ID' | 'UPDATE_PROJECT_BY_ID' | 'PUBLIC_KEY_GET' - | 'CERTIFICATE_LIST'; + | 'CERTIFICATE_LIST' + | 'TRIAL_BALANCE_LIST_V2' + | 'LEDGER_LIST_V2'; export type IHttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'; diff --git a/src/lib/utils/zod_schema/ledger.ts b/src/lib/utils/zod_schema/ledger.ts new file mode 100644 index 00000000..eef356ba --- /dev/null +++ b/src/lib/utils/zod_schema/ledger.ts @@ -0,0 +1,60 @@ +import { IZodValidator } from '@/interfaces/zod_validator'; +import { z } from 'zod'; +import { zodStringToNumberWithDefault, zodTimestampInSeconds } from '@/lib/utils/zod_schema/common'; +import { DEFAULT_PAGE_NUMBER } from '@/constants/display'; +import { DEFAULT_PAGE_LIMIT } from '@/constants/config'; +import { SortOrder } from '@/constants/sort'; + +const ledgerListQueryValidator = z.object({ + page: zodStringToNumberWithDefault(DEFAULT_PAGE_NUMBER), + pageSize: zodStringToNumberWithDefault(DEFAULT_PAGE_LIMIT), + sortOrder: z.nativeEnum(SortOrder).optional(), + startDate: zodTimestampInSeconds(true, 0), + endDate: zodTimestampInSeconds(true, Infinity), + startAccountNo: z.string().optional(), + endAccountNo: z.string().optional(), + labelType: z.enum(['general', 'detailed', 'all']).optional(), +}); + +export const ledgerReturnValidator = z.object({ + id: z.number(), + voucherDate: z.number(), + no: z.string(), + accountingTitle: z.string(), + voucherNumber: z.string(), + particulars: z.string(), + debitAmount: z.number(), + creditAmount: z.number(), + balance: z.number(), + createAt: z.number(), + updateAt: z.number(), +}); + +export const ledgerListReturnValidator = z.object({ + currency: z.string(), + items: z.object({ + data: z.array(ledgerReturnValidator), + page: z.number(), + totalPages: z.number(), + totalCount: z.number(), + pageSize: z.number(), + hasNextPage: z.boolean(), + hasPreviousPage: z.boolean(), + sort: z.array( + z.object({ + sortBy: z.string(), + sortOrder: z.string(), + }) + ), + }), + totalDebitAmount: z.number(), + totalCreditAmount: z.number(), +}); + +export const ledgerListValidator: IZodValidator< + (typeof ledgerListQueryValidator)['shape'], + z.ZodOptional> +> = { + query: ledgerListQueryValidator, + body: z.string().nullable().optional(), +}; diff --git a/src/lib/utils/zod_schema/trial_balance.ts b/src/lib/utils/zod_schema/trial_balance.ts new file mode 100644 index 00000000..65bc9741 --- /dev/null +++ b/src/lib/utils/zod_schema/trial_balance.ts @@ -0,0 +1,53 @@ +import { IZodValidator } from '@/interfaces/zod_validator'; +import { z } from 'zod'; +import { zodStringToNumberWithDefault, zodTimestampInSeconds } from '@/lib/utils/zod_schema/common'; +import { DEFAULT_PAGE_NUMBER } from '@/constants/display'; +import { DEFAULT_PAGE_LIMIT } from '@/constants/config'; +import { SortOrder } from '@/constants/sort'; + +const trialBalanceListQueryValidator = z.object({ + page: zodStringToNumberWithDefault(DEFAULT_PAGE_NUMBER), + pageSize: zodStringToNumberWithDefault(DEFAULT_PAGE_LIMIT), + sortOrder: z.nativeEnum(SortOrder).optional(), + startDate: zodTimestampInSeconds(true, 0), + endDate: zodTimestampInSeconds(true, Infinity), +}); + +const basicTrialBalanceReturnValidator = z.object({ + id: z.number(), + no: z.string(), + accountingTitle: z.string(), + beginningCreditAmount: z.number(), + beginningDebitAmount: z.number(), + midtermCreditAmount: z.number(), + midtermDebitAmount: z.number(), + endingCreditAmount: z.number(), + endingDebitAmount: z.number(), + createAt: z.number(), + updateAt: z.number(), +}); + +export const trialBalanceReturnValidator = z.object({ + id: z.number(), + no: z.string(), + accountingTitle: z.string(), + beginningCreditAmount: z.number(), + beginningDebitAmount: z.number(), + midtermCreditAmount: z.number(), + midtermDebitAmount: z.number(), + endingCreditAmount: z.number(), + endingDebitAmount: z.number(), + createAt: z.number(), + updateAt: z.number(), + subAccounts: z.array(basicTrialBalanceReturnValidator), +}); + +export const trialBalanceListReturnValidator = z.array(trialBalanceReturnValidator); + +export const trialBalanceListValidator: IZodValidator< + (typeof trialBalanceListQueryValidator)['shape'], + z.ZodOptional> +> = { + query: trialBalanceListQueryValidator, + body: z.string().nullable().optional(), +}; diff --git a/src/pages/api/v2/company/[companyId]/ledger/index.test.ts b/src/pages/api/v2/company/[companyId]/ledger/index.test.ts new file mode 100644 index 00000000..6e603fb8 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/ledger/index.test.ts @@ -0,0 +1,67 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { handleGetRequest } from '@/pages/api/v2/company/[companyId]/ledger/index'; +import { STATUS_MESSAGE } from '@/constants/status_code'; + +let req: jest.Mocked; +let res: jest.Mocked; + +jest.mock('../../../../../../lib/utils/session.ts', () => ({ + getSession: jest.fn().mockResolvedValue({ + userId: 1001, + companyId: 1001, + }), +})); + +jest.mock('../../../../../../lib/utils/request_validator', () => ({ + validateRequest: jest.fn().mockReturnValue({ + query: { + startDate: '2024-01-01', + endDate: '2024-12-31', + startAccountNo: '1000', + endAccountNo: '9999', + labelType: 'all', + page: 1, + pageSize: 10, + sortOrder: 'asc', + }, + }), +})); + +beforeEach(() => { + req = { + headers: {}, + query: {}, + method: '', + socket: {}, + json: jest.fn(), + } as unknown as jest.Mocked; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as jest.Mocked; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('company/[companyId]/ledger', () => { + describe('GET Ledger List', () => { + it('should return ledger list', async () => { + req.query = { + companyId: '1001', + }; + + const { payload, statusMessage } = await handleGetRequest(req, res); + + expect(statusMessage).toBe(STATUS_MESSAGE.SUCCESS_LIST); + expect(payload).toHaveProperty('currency', 'TWD'); + expect(payload).toHaveProperty('items'); + expect(payload?.items).toHaveProperty('data'); + expect(payload?.items.data).toBeInstanceOf(Array); + expect(payload).toHaveProperty('totalDebitAmount'); + expect(payload).toHaveProperty('totalCreditAmount'); + }); + }); +}); diff --git a/src/pages/api/v2/company/[companyId]/ledger/index.ts b/src/pages/api/v2/company/[companyId]/ledger/index.ts new file mode 100644 index 00000000..11d93657 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/ledger/index.ts @@ -0,0 +1,123 @@ +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { IResponseData } from '@/interfaces/response_data'; +import { formatApiResponse } from '@/lib/utils/common'; +import { getSession } from '@/lib/utils/session'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { validateRequest } from '@/lib/utils/request_validator'; +import { APIName } from '@/constants/api_connection'; +import { mockLedgerList } from '@/pages/api/v2/company/[companyId]/ledger/service'; + +type APIResponse = { + currency: string; + items: { + data: Array<{ + id: number; + voucherDate: number; + no: string; + accountingTitle: string; + voucherNumber: string; + particulars: string; + debitAmount: number; + creditAmount: number; + balance: number; + createAt: number; + updateAt: number; + }>; + page: number; + totalPages: number; + totalCount: number; + pageSize: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + sort: Array<{ + sortBy: string; + sortOrder: string; + }>; + }; + totalDebitAmount: number; + totalCreditAmount: number; +} | null; + +export async function handleGetRequest(req: NextApiRequest, res: NextApiResponse) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: APIResponse = null; + + // TODO: 實作時確認 auth (20240926 - Shirley) + const session = await getSession(req, res); + const { userId } = session; + + const { query } = validateRequest(APIName.LEDGER_LIST_V2, req, userId); + + if (query) { + // TODO: 實作 API 時補上 (20240926 - Shirley) + /* eslint-disable @typescript-eslint/no-unused-vars */ + const { + startDate, + endDate, + startAccountNo, + endAccountNo, + labelType, + page, + pageSize, + sortOrder, + } = query; + + statusMessage = STATUS_MESSAGE.SUCCESS_LIST; + payload = { + currency: 'TWD', + items: { + data: mockLedgerList, + page: page ?? 1, + totalPages: 1, + totalCount: mockLedgerList.length, + pageSize: pageSize ?? mockLedgerList.length, + hasNextPage: false, + hasPreviousPage: false, + sort: [ + { + sortBy: 'no', + sortOrder: 'asc', + }, + ], + }, + totalDebitAmount: 2300000, + totalCreditAmount: 1300000, + }; + } + + return { + statusMessage, + payload, + }; +} + +const methodHandlers: { + [key: string]: ( + req: NextApiRequest, + res: NextApiResponse + ) => Promise<{ statusMessage: string; payload: APIResponse }>; +} = { + GET: handleGetRequest, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse> +) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: APIResponse = null; + + try { + const handleRequest = methodHandlers[req.method || '']; + if (handleRequest) { + ({ statusMessage, payload } = await handleRequest(req, res)); + } else { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + } + } catch (_error) { + const error = _error as Error; + statusMessage = error.message; + } + const { httpCode, result } = formatApiResponse(statusMessage, payload); + res.status(httpCode).json(result); +} diff --git a/src/pages/api/v2/company/[companyId]/ledger/service.ts b/src/pages/api/v2/company/[companyId]/ledger/service.ts new file mode 100644 index 00000000..7b818e19 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/ledger/service.ts @@ -0,0 +1,28 @@ +export const mockLedgerList = [ + { + id: 1, + voucherDate: 1725181756, + no: '1141', + accountingTitle: '應收帳款', + voucherNumber: 'V001', + particulars: '銷售商品', + debitAmount: 10000, + creditAmount: 0, + balance: 10000, + createAt: 1725527356, + updateAt: 1725700156, + }, + { + id: 2, + voucherDate: 1725181756, + no: '1141', + accountingTitle: '應收帳款', + voucherNumber: 'V001', + particulars: '銷售商品', + debitAmount: 10000, + creditAmount: 0, + balance: 10000, + createAt: 1725527356, + updateAt: 1725700156, + }, +]; diff --git a/src/pages/api/v2/company/[companyId]/trial_balance/index.test.ts b/src/pages/api/v2/company/[companyId]/trial_balance/index.test.ts new file mode 100644 index 00000000..52023ffe --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/trial_balance/index.test.ts @@ -0,0 +1,75 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { handleGetRequest } from '@/pages/api/v2/company/[companyId]/trial_balance/index'; +import { STATUS_MESSAGE } from '@/constants/status_code'; + +let req: jest.Mocked; +let res: jest.Mocked; + +jest.mock('../../../../../../lib/utils/session.ts', () => ({ + getSession: jest.fn().mockResolvedValue({ + userId: 1001, + companyId: 1001, + }), +})); + +jest.mock('../../../../../../lib/utils/request_validator', () => ({ + validateRequest: jest.fn().mockReturnValue({ + query: { + startDate: '2024-01-01', + endDate: '2024-12-31', + page: 1, + pageSize: 10, + sortOrder: 'asc', + }, + }), +})); + +beforeEach(() => { + req = { + headers: {}, + query: {}, + method: '', + socket: {}, + json: jest.fn(), + } as unknown as jest.Mocked; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as jest.Mocked; +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('company/[companyId]/trial_balance', () => { + describe('GET Trial Balance List', () => { + it('should return trial balance list', async () => { + req.query = { + companyId: '1001', + }; + + const { payload, statusMessage } = await handleGetRequest(req, res); + + expect(statusMessage).toBe(STATUS_MESSAGE.SUCCESS_LIST); + expect(payload).toHaveProperty('currency', 'TWD'); + expect(payload).toHaveProperty('items'); + if (payload && 'items' in payload) { + expect(payload.items).toHaveProperty('data'); + + const items = payload.items as { data?: unknown[] }; + expect(items).toHaveProperty('data'); + if ('data' in items) { + expect(items.data).toBeInstanceOf(Array); + } + } + expect(payload).toHaveProperty('total'); + if (payload && 'total' in payload) { + expect(payload.total).toHaveProperty('beginningCreditAmount'); + expect(payload.total).toHaveProperty('beginningDebitAmount'); + expect(payload.total).toHaveProperty('midtermCreditAmount'); + } + }); + }); +}); diff --git a/src/pages/api/v2/company/[companyId]/trial_balance/index.ts b/src/pages/api/v2/company/[companyId]/trial_balance/index.ts new file mode 100644 index 00000000..a533cb67 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/trial_balance/index.ts @@ -0,0 +1,96 @@ +import { STATUS_MESSAGE } from '@/constants/status_code'; +import { IResponseData } from '@/interfaces/response_data'; +import { formatApiResponse } from '@/lib/utils/common'; +import { getSession } from '@/lib/utils/session'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { mockTrialBalanceList } from '@/pages/api/v2/company/[companyId]/trial_balance/service'; +import { validateRequest } from '@/lib/utils/request_validator'; +import { APIName } from '@/constants/api_connection'; + +type APIResponse = + | object + | { + currency: string; + items: unknown; + total: unknown; + } + | null; + +export async function handleGetRequest(req: NextApiRequest, res: NextApiResponse) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: APIResponse = null; + + const session = await getSession(req, res); + const { userId } = session; + + const { query } = validateRequest(APIName.TRIAL_BALANCE_LIST_V2, req, userId); + + if (query) { + const { startDate, endDate, page, pageSize, sortOrder } = query; + statusMessage = STATUS_MESSAGE.SUCCESS_LIST; + payload = { + currency: 'TWD', + items: { + data: mockTrialBalanceList, + page, + totalPages: 1, + totalCount: mockTrialBalanceList.length, + pageSize, + hasNextPage: false, + hasPreviousPage: false, + sort: [ + { + sortBy: 'no', + sortOrder, + }, + ], + }, + total: { + beginningCreditAmount: 0, + beginningDebitAmount: 2285000, + midtermCreditAmount: 0, + midtermDebitAmount: 2285000, + endingCreditAmount: 0, + endingDebitAmount: 2285000, + createAt: startDate, + updateAt: endDate, + }, + }; + } + + return { + statusMessage, + payload, + }; +} + +const methodHandlers: { + [key: string]: ( + req: NextApiRequest, + res: NextApiResponse + ) => Promise<{ statusMessage: string; payload: APIResponse }>; +} = { + GET: handleGetRequest, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse> +) { + let statusMessage: string = STATUS_MESSAGE.BAD_REQUEST; + let payload: APIResponse = null; + + try { + const handleRequest = methodHandlers[req.method || '']; + if (handleRequest) { + ({ statusMessage, payload } = await handleRequest(req, res)); + } else { + statusMessage = STATUS_MESSAGE.METHOD_NOT_ALLOWED; + } + } catch (_error) { + const error = _error as Error; + statusMessage = error.message; + } + const { httpCode, result } = formatApiResponse(statusMessage, payload); + res.status(httpCode).json(result); +} diff --git a/src/pages/api/v2/company/[companyId]/trial_balance/service.ts b/src/pages/api/v2/company/[companyId]/trial_balance/service.ts new file mode 100644 index 00000000..c770c054 --- /dev/null +++ b/src/pages/api/v2/company/[companyId]/trial_balance/service.ts @@ -0,0 +1,45 @@ +export const mockTrialBalanceList = [ + { + id: 1, + no: '1141', + accountingTitle: '應收帳款', + beginningCreditAmount: 0, + beginningDebitAmount: 1785000, + midtermCreditAmount: 0, + midtermDebitAmount: 1785000, + endingCreditAmount: 0, + endingDebitAmount: 1785000, + createAt: 1704067200, + updateAt: 1704067200, + subAccounts: [ + { + id: 2, + no: '114101', + accountingTitle: '應收帳款-A公司', + beginningCreditAmount: 0, + beginningDebitAmount: 1785000, + midtermCreditAmount: 0, + midtermDebitAmount: 1785000, + endingCreditAmount: 0, + endingDebitAmount: 1785000, + createAt: 1704067200, + updateAt: 1704067200, + subAccounts: [], + }, + ], + }, + { + id: 3, + no: '1151', + accountingTitle: '其他應收款', + beginningCreditAmount: 0, + beginningDebitAmount: 500000, + midtermCreditAmount: 0, + midtermDebitAmount: 500000, + endingCreditAmount: 0, + endingDebitAmount: 500000, + createAt: 1704067200, + updateAt: 1704067200, + subAccounts: [], + }, +];