From efe321ca2f3055e1d5b91aba3c244c9bdcab41cb Mon Sep 17 00:00:00 2001 From: shancheas Date: Mon, 31 Aug 2026 15:14:58 +0700 Subject: [PATCH] feat: implement payable invoice functionality and enhance invoice processing - Introduced `isPayableInvoice` utility to determine the payability of invoices based on status and balance. - Updated sales invoice detail page to conditionally create payment actions for payable invoices. - Refactored form allocations to load only payable sales invoices, improving data integrity in payment forms. - Enhanced sales payment form to validate invoice payability before processing. - Added unit tests for `isPayableInvoice` to ensure accurate functionality. - Updated sales status transitions to allow draft invoices to be processed or cancelled, improving workflow flexibility. These changes enhance the sales invoicing process by ensuring that only valid invoices are processed for payments, improving overall user experience and data management. --- .../languages/en/sales-invoices.json | 1 + .../languages/id/sales-invoices.json | 1 + .../pages/sales-invoice.page.detail.tsx | 12 ++++++++---- .../form-component/form-allocations.tsx | 10 ++++++---- .../pages/sales-payment.page.form.tsx | 3 ++- .../shared/load-payable-sales-invoice-options.ts | 8 ++++++++ .../modules/sales/shared/payable-invoice.test.ts | 16 ++++++++++++++++ .../main/modules/sales/shared/payable-invoice.ts | 13 +++++++++++++ .../modules/sales/shared/sales-status.test.ts | 4 +++- .../main/modules/sales/shared/sales-status.ts | 3 ++- 10 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts create mode 100644 apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts create mode 100644 apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json index 91c1c67..7265b7f 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/en/sales-invoices.json @@ -23,6 +23,7 @@ "import_success": "CSV imported.", "status_updated": "Status updated.", "create_sales_payment": "Create Sales Payment", + "action_process": "Process", "action_cancel": "Cancel", "status_draft": "Draft", "status_processed": "Processed", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json index 65a84b4..3464ad6 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/languages/id/sales-invoices.json @@ -23,6 +23,7 @@ "import_success": "CSV berhasil diimpor.", "status_updated": "Status diperbarui.", "create_sales_payment": "Buat Pembayaran Penjualan", + "action_process": "Proses", "action_cancel": "Batalkan", "status_draft": "Draft", "status_processed": "Diproses", diff --git a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx index 76b5ee8..b43cad9 100644 --- a/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx +++ b/apps/web/src/apps/main/modules/sales/invoices/presentation/pages/sales-invoice.page.detail.tsx @@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general'; import { DetailLocation } from '../../../shared/detail-location'; import { DetailProducts } from '../../../shared/detail-products'; import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions'; +import { isPayableInvoice } from '../../../shared/payable-invoice'; import { salesOrdersModuleConfig } from '../../../orders/domain/constants'; import { salesPaymentsModuleConfig } from '../../../payments/domain/constants'; import type { SalesInvoiceEntity } from '../../domain/entities'; @@ -27,10 +28,13 @@ export default function SalesInvoicePageDetail() { ], }} customPageActions={(data, pageActions) => { - const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => { - navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`); - }); - const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []); + const invoice = data as SalesInvoiceEntity; + const createPayment = isPayableInvoice(invoice) + ? actions.createPaymentAction(invoice, () => { + navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`); + }) + : null; + const withStatus = actions.detailStatusActions(invoice, pageActions ?? []); return createPayment ? [createPayment, ...withStatus] : withStatus; }} > diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx index a273138..79c0b22 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/components/form-component/form-allocations.tsx @@ -12,9 +12,10 @@ import { import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; import { useFieldArray, useWatch } from '@repo/ui/form'; import { Plus, Trash2 } from 'lucide-react'; -import { loadSalesInvoiceOptions } from '../../../../../field/shared/lookup.factories'; +import { loadPayableSalesInvoiceOptions } from '../../../../shared/load-payable-sales-invoice-options'; +import { isPayableInvoice } from '../../../../shared/payable-invoice'; import { relationLabel } from '../../../../../field/shared/relation-label'; -import type { LookupEntity } from '../../../../../field/shared/lookup.entity'; +import type { SalesInvoiceEntity } from '../../../../invoices/domain/entities'; export function FormAllocations() { const { formControl } = useFormPageContext(); @@ -45,13 +46,14 @@ export function FormAllocations() { return ( - + control={formControl.control} name={`invoices.${index}.invoice`} valueKey="id" labelKey="code" searchable - loadOptions={loadSalesInvoiceOptions} + loadOptions={loadPayableSalesInvoiceOptions} + filterOption={(item) => isPayableInvoice(item)} defaultOptions={line?.invoice ? [line.invoice] : []} renderLabel={relationLabel} /> diff --git a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx index 34f867d..0255f8c 100644 --- a/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx +++ b/apps/web/src/apps/main/modules/sales/payments/presentation/pages/sales-payment.page.form.tsx @@ -11,6 +11,7 @@ import { FormAllocations } from '../components/form-component/form-allocations'; import { FormImages } from '../../../shared/form-images'; import { FormNotes } from '../../../shared/form-notes'; import { salesInvoicesDataService } from '../../../invoices/domain/factories'; +import { isPayableInvoice } from '../../../shared/payable-invoice'; import type { SalesInvoiceEntity } from '../../../invoices/domain/entities'; export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) { @@ -46,7 +47,7 @@ export default function SalesPaymentPageForm({ formPageType }: { formPageType: F prefilled.current = true; void salesInvoicesDataService.getOne(invoiceId).then((result) => { const entity = (result.data as { data?: SalesInvoiceEntity })?.data; - if (!entity) return; + if (!entity || !isPayableInvoice(entity)) return; formControl.reset({ invoices: [ { diff --git a/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts b/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts new file mode 100644 index 0000000..001d15d --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/load-payable-sales-invoice-options.ts @@ -0,0 +1,8 @@ +import { createOptionLoader } from '../../field/shared/create-option-loader'; +import { salesInvoicesDataService } from '../invoices/domain/factories'; +import type { SalesInvoiceEntity } from '../invoices/domain/entities'; + +export const loadPayableSalesInvoiceOptions = createOptionLoader( + (config) => salesInvoicesDataService.getMany(config), + { payable: true }, +); diff --git a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts new file mode 100644 index 0000000..edfc4c1 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isPayableInvoice } from './payable-invoice'; + +describe('isPayableInvoice', () => { + it('accepts processed and partial invoices with remaining balance', () => { + expect(isPayableInvoice({ status: 'processed', balance: '25000.0000' })).toBe(true); + expect(isPayableInvoice({ status: 'partial', balance: '1' })).toBe(true); + }); + + it('rejects draft invoices and invoices with no remaining balance', () => { + expect(isPayableInvoice({ status: 'draft', balance: '25000.0000' })).toBe(false); + expect(isPayableInvoice({ status: 'processed', balance: '0.0000' })).toBe(false); + expect(isPayableInvoice({ status: 'completed', balance: '0' })).toBe(false); + expect(isPayableInvoice({ status: 'cancelled', balance: '10000' })).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts new file mode 100644 index 0000000..10a7469 --- /dev/null +++ b/apps/web/src/apps/main/modules/sales/shared/payable-invoice.ts @@ -0,0 +1,13 @@ +export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const; + +export function isPayableInvoice(invoice: { + status?: string | null; + balance?: string | number | null; +}): boolean { + const payable: readonly string[] = PAYABLE_INVOICE_STATUSES; + if (!invoice.status || !payable.includes(invoice.status)) { + return false; + } + const balance = Number(invoice.balance); + return Number.isFinite(balance) && balance > 0; +} diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts index cb99720..ff7dc31 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.test.ts @@ -26,9 +26,11 @@ describe('sales status transitions', () => { expect(namedActionsFor('order', 'processed').map((action) => action.key)).toEqual(['cancel']); }); - it('limits invoice user transitions to cancelled', () => { + it('allows invoice draft to processed or cancelled', () => { + expect(allowedTransitions('invoice', 'draft')).toEqual(['processed', 'cancelled']); expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']); expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']); + expect(namedActionsFor('invoice', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']); expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']); }); diff --git a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts index ada63e5..e7f57c8 100644 --- a/apps/web/src/apps/main/modules/sales/shared/sales-status.ts +++ b/apps/web/src/apps/main/modules/sales/shared/sales-status.ts @@ -37,7 +37,7 @@ const PACKING_TRANSITIONS: Record = { }; const INVOICE_TRANSITIONS: Record = { - draft: ['cancelled'], + draft: ['processed', 'cancelled'], processed: ['cancelled'], partial: ['cancelled'], completed: ['cancelled'], @@ -61,6 +61,7 @@ export const PACKING_SLIP_NAMED_ACTIONS = [ ] as const; export const SALES_INVOICE_NAMED_ACTIONS = [ + { key: 'process', target: 'processed' as const, from: ['draft'] as const }, { key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] as const }, ] as const;