Compare commits

...

12 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
441e27581b Merge pull request #949 from bigcapitalhq/fix-tax-rates
fix: tax rates API and UI improvements
2026-02-12 20:08:49 +02:00
Ahmed Bouhuolia
e0d9a56a29 fix: tax rates API and UI improvements
- Add @ToNumber() decorator to rate field for proper validation
- Fix getTaxRates to return { data: taxRates } response
- Fix useTaxRate URL typo and response handling
- Fix activate/inactivate endpoint methods and paths
- Apply TEXT_MUTED class to description and compound tax
- Add dark mode support for rate number display
2026-02-12 20:06:49 +02:00
Ahmed Bouhuolia
5a017104ce Merge pull request #948 from bigcapitalhq/fix/abouolia/rerecognize-transactions-on-rule-edit
fix: paper template scrollable area
2026-02-12 15:02:12 +02:00
Ahmed Bouhuolia
25ca620836 fix: add consistent Box wrapper to paper template forms in customize components 2026-02-12 14:59:55 +02:00
Ahmed Bouhuolia
5a3655e093 Merge pull request #944 from bigcapitalhq/fix/abouolia/rerecognize-transactions-on-rule-edit
fix(server): re-recognize transactions when bank rule is edited
2026-02-11 23:18:18 +02:00
Ahmed Bouhuolia
49c2777587 fix: re-recognize transactions when bank rule is edited (closes #809) 2026-02-11 23:15:20 +02:00
Ahmed Bouhuolia
a5680c08c2 Merge pull request #943 from bigcapitalhq/fix/abouolia/bank-rule-payee-field-validation
fix(server): add missing S3_FORCE_PATH_STYLE environment variable
2026-02-11 19:36:52 +02:00
Ahmed Bouhuolia
d909dad1bf fix: add missing S3_FORCE_PATH_STYLE environment variable
The S3 module was referencing config.forcePathStyle but the value
was never being read from the environment. This adds the missing
forcePathStyle configuration to the S3 config.

Closes #940

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 19:35:21 +02:00
Ahmed Bouhuolia
f32cc752ef Merge pull request #942 from bigcapitalhq/fix/abouolia/bank-rule-payee-field-validation
fix(server): allow 'payee' field in bank rule conditions validation
2026-02-11 19:13:35 +02:00
Ahmed Bouhuolia
a7f98201cc fix: allow 'payee' field in bank rule conditions validation
The BankRuleConditionDto validation only allowed 'description' and 'amount'
fields, but the frontend also sends 'payee' as a valid condition field.
This caused a 400 Bad Request error when creating rules with payee conditions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 19:11:58 +02:00
Ahmed Bouhuolia
a1d0fc3f0a Merge pull request #941 from bigcapitalhq/fix/ahmedbouhuolia/phone-validation-formatted-numbers
fix(webapp): allow formatted phone numbers in customer and vendor forms
2026-02-11 18:39:52 +02:00
Ahmed Bouhuolia
11575cfb96 fix(webapp): allow formatted phone numbers in customer and vendor forms 2026-02-11 18:37:39 +02:00
18 changed files with 84 additions and 39 deletions

View File

@@ -6,4 +6,5 @@ export default registerAs('s3', () => ({
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
endpoint: process.env.S3_ENDPOINT, endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET, bucket: process.env.S3_BUCKET,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true',
})); }));

View File

@@ -16,7 +16,7 @@ import { ToNumber } from '@/common/decorators/Validators';
class BankRuleConditionDto { class BankRuleConditionDto {
@IsNotEmpty() @IsNotEmpty()
@IsIn(['description', 'amount']) @IsIn(['description', 'amount', 'payee'])
field: string; field: string;
@IsNotEmpty() @IsNotEmpty()

View File

@@ -15,8 +15,13 @@ export const RecognizeUncategorizedTransactionsJob =
export const RecognizeUncategorizedTransactionsQueue = export const RecognizeUncategorizedTransactionsQueue =
'recognize-uncategorized-transactions-queue'; 'recognize-uncategorized-transactions-queue';
export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload { export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload {
ruleId: number, ruleId: number,
transactionsCriteria: any; transactionsCriteria?: RecognizeTransactionsCriteria;
/**
* When true, first reverts recognized transactions before recognizing again.
* Used when a bank rule is edited to ensure transactions previously recognized
* by lower-priority rules are re-evaluated against the updated rule.
*/
shouldRevert?: boolean;
} }

View File

@@ -93,6 +93,10 @@ export class RecognizeTranasctionsService {
q.whereIn('id', rulesIds); q.whereIn('id', rulesIds);
} }
q.withGraphFetched('conditions'); q.withGraphFetched('conditions');
// Order by the 'order' field to ensure higher priority rules (lower order values)
// are matched first.
q.orderBy('order', 'asc');
}); });
const bankRulesByAccountId = transformToMapBy( const bankRulesByAccountId = transformToMapBy(

View File

@@ -69,10 +69,13 @@ export class TriggerRecognizedTransactionsSubscriber {
const tenantPayload = await this.tenancyContect.getTenantJobPayload(); const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = { const payload = {
ruleId: bankRule.id, ruleId: bankRule.id,
shouldRevert: true,
...tenantPayload, ...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload; } as RecognizeUncategorizedTransactionsJobPayload;
// Re-recognize the transactions based on the new rules. // Re-recognize the transactions based on the new rules.
// Setting shouldRevert to true ensures that transactions previously recognized
// by this or lower-priority rules are re-evaluated against the updated rule.
await this.recognizeTransactionsQueue.add( await this.recognizeTransactionsQueue.add(
RecognizeUncategorizedTransactionsJob, RecognizeUncategorizedTransactionsJob,
payload, payload,

View File

@@ -3,6 +3,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common'; import { Scope } from '@nestjs/common';
import { ClsService, UseCls } from 'nestjs-cls'; import { ClsService, UseCls } from 'nestjs-cls';
import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service'; import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
import { RevertRecognizedTransactionsService } from '../commands/RevertRecognizedTransactions.service';
import { import {
RecognizeUncategorizedTransactionsJobPayload, RecognizeUncategorizedTransactionsJobPayload,
RecognizeUncategorizedTransactionsQueue, RecognizeUncategorizedTransactionsQueue,
@@ -15,10 +16,12 @@ import {
export class RegonizeTransactionsPrcessor extends WorkerHost { export class RegonizeTransactionsPrcessor extends WorkerHost {
/** /**
* @param {RecognizeTranasctionsService} recognizeTranasctionsService - * @param {RecognizeTranasctionsService} recognizeTranasctionsService -
* @param {RevertRecognizedTransactionsService} revertRecognizedTransactionsService -
* @param {ClsService} clsService - * @param {ClsService} clsService -
*/ */
constructor( constructor(
private readonly recognizeTranasctionsService: RecognizeTranasctionsService, private readonly recognizeTranasctionsService: RecognizeTranasctionsService,
private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService,
private readonly clsService: ClsService, private readonly clsService: ClsService,
) { ) {
super(); super();
@@ -29,12 +32,21 @@ export class RegonizeTransactionsPrcessor extends WorkerHost {
*/ */
@UseCls() @UseCls()
async process(job: Job<RecognizeUncategorizedTransactionsJobPayload>) { async process(job: Job<RecognizeUncategorizedTransactionsJobPayload>) {
const { ruleId, transactionsCriteria } = job.data; const { ruleId, transactionsCriteria, shouldRevert } = job.data;
this.clsService.set('organizationId', job.data.organizationId); this.clsService.set('organizationId', job.data.organizationId);
this.clsService.set('userId', job.data.userId); this.clsService.set('userId', job.data.userId);
try { try {
// If shouldRevert is true, first revert recognized transactions before re-recognizing.
// This is used when a bank rule is edited to ensure transactions previously recognized
// by lower-priority rules are re-evaluated against the updated rule.
if (shouldRevert) {
await this.revertRecognizedTransactionsService.revertRecognizedTransactions(
ruleId,
transactionsCriteria,
);
}
await this.recognizeTranasctionsService.recognizeTransactions( await this.recognizeTranasctionsService.recognizeTransactions(
ruleId, ruleId,
transactionsCriteria, transactionsCriteria,

View File

@@ -62,10 +62,11 @@ export class TaxRatesApplication {
/** /**
* Retrieves the tax rates list. * Retrieves the tax rates list.
* @returns {Promise<ITaxRate[]>} * @returns {Promise<{ data: ITaxRate[] }>}
*/ */
public getTaxRates() { public async getTaxRates() {
return this.getTaxRatesService.getTaxRates(); const taxRates = await this.getTaxRatesService.getTaxRates();
return { data: taxRates };
} }
/** /**

View File

@@ -85,11 +85,16 @@ export class TaxRatesController {
status: 200, status: 200,
description: 'The tax rates have been successfully retrieved.', description: 'The tax rates have been successfully retrieved.',
schema: { schema: {
type: 'object',
properties: {
data: {
type: 'array', type: 'array',
items: { items: {
$ref: getSchemaPath(TaxRateResponseDto), $ref: getSchemaPath(TaxRateResponseDto),
}, },
}, },
},
},
}) })
public getTaxRates() { public getTaxRates() {
return this.taxRatesApplication.getTaxRates(); return this.taxRatesApplication.getTaxRates();

View File

@@ -1,3 +1,4 @@
import { ToNumber } from '@/common/decorators/Validators';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
@@ -30,6 +31,7 @@ export class CommandTaxRateDto {
*/ */
@IsNumber() @IsNumber()
@IsNotEmpty() @IsNotEmpty()
@ToNumber()
@ApiProperty({ @ApiProperty({
description: 'The rate of the tax rate.', description: 'The rate of the tax rate.',
example: 10, example: 10,

View File

@@ -17,8 +17,8 @@ const Schema = Yup.object().shape({
.label(intl.get('display_name_')), .label(intl.get('display_name_')),
email: Yup.string().email().nullable(), email: Yup.string().email().nullable(),
work_phone: Yup.number(), work_phone: Yup.string().nullable(),
personal_phone: Yup.number(), personal_phone: Yup.string().nullable(),
website: Yup.string().url().nullable(), website: Yup.string().url().nullable(),
active: Yup.boolean(), active: Yup.boolean(),
@@ -30,7 +30,7 @@ const Schema = Yup.object().shape({
billing_address_city: Yup.string().trim(), billing_address_city: Yup.string().trim(),
billing_address_state: Yup.string().trim(), billing_address_state: Yup.string().trim(),
billing_address_postcode: Yup.string().nullable(), billing_address_postcode: Yup.string().nullable(),
billing_address_phone: Yup.number(), billing_address_phone: Yup.string().nullable(),
shipping_address_country: Yup.string().trim(), shipping_address_country: Yup.string().trim(),
shipping_address_1: Yup.string().trim(), shipping_address_1: Yup.string().trim(),
@@ -38,7 +38,7 @@ const Schema = Yup.object().shape({
shipping_address_city: Yup.string().trim(), shipping_address_city: Yup.string().trim(),
shipping_address_state: Yup.string().trim(), shipping_address_state: Yup.string().trim(),
shipping_address_postcode: Yup.string().nullable(), shipping_address_postcode: Yup.string().nullable(),
shipping_address_phone: Yup.number(), shipping_address_phone: Yup.string().nullable(),
opening_balance: Yup.number().nullable(), opening_balance: Yup.number().nullable(),
currency_code: Yup.string(), currency_code: Yup.string(),

View File

@@ -16,6 +16,7 @@ import { useDrawerActions } from '@/hooks/state';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider'; import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils'; import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Box } from '@/components';
export function CreditNoteCustomizeContent() { export function CreditNoteCustomizeContent() {
const { payload, name } = useDrawerContext(); const { payload, name } = useDrawerContext();
@@ -45,7 +46,9 @@ function CreditNoteCustomizeFormContent() {
return ( return (
<ElementCustomizeContent> <ElementCustomizeContent>
<ElementCustomize.PaperTemplate> <ElementCustomize.PaperTemplate>
<Box overflow="auto" flex="1 1" px={4} py={6}>
<CreditNotePaperTemplateFormConnected /> <CreditNotePaperTemplateFormConnected />
</Box>
</ElementCustomize.PaperTemplate> </ElementCustomize.PaperTemplate>
<ElementCustomize.FieldsTab id={'general'} label={'General'}> <ElementCustomize.FieldsTab id={'general'} label={'General'}>

View File

@@ -16,6 +16,7 @@ import { useDrawerActions } from '@/hooks/state';
import { BrandingTemplateForm } from '@/containers/BrandingTemplates/BrandingTemplateForm'; import { BrandingTemplateForm } from '@/containers/BrandingTemplates/BrandingTemplateForm';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider'; import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils'; import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Box } from '@/components';
export function EstimateCustomizeContent() { export function EstimateCustomizeContent() {
const { payload, name } = useDrawerContext(); const { payload, name } = useDrawerContext();
@@ -44,7 +45,9 @@ function EstimateCustomizeFormContent() {
return ( return (
<ElementCustomizeContent> <ElementCustomizeContent>
<ElementCustomize.PaperTemplate> <ElementCustomize.PaperTemplate>
<Box overflow="auto" flex="1 1" px={4} py={6}>
<EstimatePaperTemplateFormConnected /> <EstimatePaperTemplateFormConnected />
</Box>
</ElementCustomize.PaperTemplate> </ElementCustomize.PaperTemplate>
<ElementCustomize.FieldsTab id={'general'} label={'General'}> <ElementCustomize.FieldsTab id={'general'} label={'General'}>

View File

@@ -19,6 +19,7 @@ import { useDrawerActions } from '@/hooks/state';
import { BrandingTemplateForm } from '@/containers/BrandingTemplates/BrandingTemplateForm'; import { BrandingTemplateForm } from '@/containers/BrandingTemplates/BrandingTemplateForm';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider'; import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils'; import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Box } from '@/components';
export function PaymentReceivedCustomizeContent() { export function PaymentReceivedCustomizeContent() {
const { payload, name } = useDrawerContext(); const { payload, name } = useDrawerContext();
@@ -51,7 +52,9 @@ function PaymentReceivedCustomizeFormContent() {
return ( return (
<ElementCustomizeContent> <ElementCustomizeContent>
<ElementCustomize.PaperTemplate> <ElementCustomize.PaperTemplate>
<Box overflow="auto" flex="1 1" px={4} py={6}>
<PaymentReceivedPaperTemplateFormConnected /> <PaymentReceivedPaperTemplateFormConnected />
</Box>
</ElementCustomize.PaperTemplate> </ElementCustomize.PaperTemplate>
<ElementCustomize.FieldsTab id={'general'} label={'General'}> <ElementCustomize.FieldsTab id={'general'} label={'General'}>

View File

@@ -16,6 +16,7 @@ import { useDrawerActions } from '@/hooks/state';
import { useDrawerContext } from '@/components/Drawer/DrawerProvider'; import { useDrawerContext } from '@/components/Drawer/DrawerProvider';
import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider'; import { useElementCustomizeContext } from '@/containers/ElementCustomize/ElementCustomizeProvider';
import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils'; import { useIsTemplateNamedFilled } from '@/containers/BrandingTemplates/utils';
import { Box } from '@/components';
export function ReceiptCustomizeContent() { export function ReceiptCustomizeContent() {
const { payload, name } = useDrawerContext(); const { payload, name } = useDrawerContext();
@@ -44,7 +45,9 @@ function ReceiptCustomizeFormContent() {
return ( return (
<ElementCustomizeContent> <ElementCustomizeContent>
<ElementCustomize.PaperTemplate> <ElementCustomize.PaperTemplate>
<Box overflow="auto" flex="1 1" px={4} py={6}>
<ReceiptPaperTemplateFormConnected /> <ReceiptPaperTemplateFormConnected />
</Box>
</ElementCustomize.PaperTemplate> </ElementCustomize.PaperTemplate>
<ElementCustomize.FieldsTab id={'general'} label={'General'}> <ElementCustomize.FieldsTab id={'general'} label={'General'}>

View File

@@ -1,8 +1,8 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { Intent, Tag } from '@blueprintjs/core'; import { Intent, Tag, Classes } from '@blueprintjs/core';
import { Align } from '@/constants'; import { Align } from '@/constants';
import styled from 'styled-components'; import clsx from 'classnames';
const codeAccessor = (taxRate) => { const codeAccessor = (taxRate) => {
return ( return (
@@ -28,13 +28,17 @@ const nameAccessor = (taxRate) => {
return ( return (
<> <>
<span>{taxRate.name}</span> <span>{taxRate.name}</span>
{!!taxRate.is_compound && <CompoundText>(Compound tax)</CompoundText>} {!!taxRate.is_compound && (
<span className={clsx(Classes.TEXT_MUTED)}>(Compound tax)</span>
)}
</> </>
); );
}; };
const DescriptionAccessor = (taxRate) => { const DescriptionAccessor = (taxRate) => {
return <DescriptionText>{taxRate.description}</DescriptionText>; return (
<span className={clsx(Classes.TEXT_MUTED)}>{taxRate.description}</span>
);
}; };
/** /**
@@ -72,11 +76,3 @@ export const useTaxRatesTableColumns = () => {
]; ];
}; };
const CompoundText = styled('span')`
color: #738091;
margin-left: 5px;
`;
const DescriptionText = styled('span')`
color: #5f6b7c;
`;

View File

@@ -74,9 +74,13 @@ const TaxRateHeader = styled(`div`)`
const TaxRateAmount = styled('div')` const TaxRateAmount = styled('div')`
line-height: 1; line-height: 1;
font-size: 30px; font-size: 30px;
color: #565b71;
font-weight: 600; font-weight: 600;
display: inline-block; display: inline-block;
color: var(--x-color-amount-text, #565b71);
.bp4-dark & {
color: rgba(255, 255, 255, 0.9);
}
`; `;
const TaxRateActiveTag = styled(Tag)` const TaxRateActiveTag = styled(Tag)`

View File

@@ -10,8 +10,8 @@ const Schema = Yup.object().shape({
display_name: Yup.string().trim().required().label(intl.get('display_name_')), display_name: Yup.string().trim().required().label(intl.get('display_name_')),
email: Yup.string().email().nullable(), email: Yup.string().email().nullable(),
work_phone: Yup.number(), work_phone: Yup.string().nullable(),
personal_phone: Yup.number(), personal_phone: Yup.string().nullable(),
website: Yup.string().url().nullable(), website: Yup.string().url().nullable(),
active: Yup.boolean(), active: Yup.boolean(),
@@ -23,7 +23,7 @@ const Schema = Yup.object().shape({
billing_address_city: Yup.string().trim(), billing_address_city: Yup.string().trim(),
billing_address_state: Yup.string().trim(), billing_address_state: Yup.string().trim(),
billing_address_postcode: Yup.string().nullable(), billing_address_postcode: Yup.string().nullable(),
billing_address_phone: Yup.number(), billing_address_phone: Yup.string().nullable(),
shipping_address_country: Yup.string().trim(), shipping_address_country: Yup.string().trim(),
shipping_address_1: Yup.string().trim(), shipping_address_1: Yup.string().trim(),
@@ -31,7 +31,7 @@ const Schema = Yup.object().shape({
shipping_address_city: Yup.string().trim(), shipping_address_city: Yup.string().trim(),
shipping_address_state: Yup.string().trim(), shipping_address_state: Yup.string().trim(),
shipping_address_postcode: Yup.string().nullable(), shipping_address_postcode: Yup.string().nullable(),
shipping_address_phone: Yup.number(), shipping_address_phone: Yup.string().nullable(),
opening_balance: Yup.number().nullable(), opening_balance: Yup.number().nullable(),
currency_code: Yup.string(), currency_code: Yup.string(),

View File

@@ -37,10 +37,10 @@ export function useTaxRate(taxRateId: string, props) {
[QUERY_TYPES.TAX_RATES, taxRateId], [QUERY_TYPES.TAX_RATES, taxRateId],
{ {
method: 'get', method: 'get',
url: `tax-rates/${taxRateId}}`, url: `tax-rates/${taxRateId}`,
}, },
{ {
select: (res) => res.data.data, select: (res) => res.data,
...props, ...props,
}, },
); );
@@ -106,7 +106,7 @@ export function useActivateTaxRate(props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useMutation((id) => apiRequest.post(`tax-rates/${id}/active`), { return useMutation((id) => apiRequest.put(`tax-rates/${id}/activate`), {
onSuccess: (res, id) => { onSuccess: (res, id) => {
commonInvalidateQueries(queryClient); commonInvalidateQueries(queryClient);
queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]); queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);
@@ -122,7 +122,7 @@ export function useInactivateTaxRate(props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useMutation((id) => apiRequest.post(`tax-rates/${id}/inactive`), { return useMutation((id) => apiRequest.put(`tax-rates/${id}/inactivate`), {
onSuccess: (res, id) => { onSuccess: (res, id) => {
commonInvalidateQueries(queryClient); commonInvalidateQueries(queryClient);
queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]); queryClient.invalidateQueries([QUERY_TYPES.TAX_RATES, id]);