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,
endpoint: process.env.S3_ENDPOINT,
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 {
@IsNotEmpty()
@IsIn(['description', 'amount'])
@IsIn(['description', 'amount', 'payee'])
field: string;
@IsNotEmpty()

View File

@@ -15,8 +15,13 @@ export const RecognizeUncategorizedTransactionsJob =
export const RecognizeUncategorizedTransactionsQueue =
'recognize-uncategorized-transactions-queue';
export interface RecognizeUncategorizedTransactionsJobPayload extends TenantJobPayload {
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.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(

View File

@@ -69,10 +69,13 @@ export class TriggerRecognizedTransactionsSubscriber {
const tenantPayload = await this.tenancyContect.getTenantJobPayload();
const payload = {
ruleId: bankRule.id,
shouldRevert: true,
...tenantPayload,
} as RecognizeUncategorizedTransactionsJobPayload;
// 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(
RecognizeUncategorizedTransactionsJob,
payload,

View File

@@ -3,6 +3,7 @@ import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Scope } from '@nestjs/common';
import { ClsService, UseCls } from 'nestjs-cls';
import { RecognizeTranasctionsService } from '../commands/RecognizeTranasctions.service';
import { RevertRecognizedTransactionsService } from '../commands/RevertRecognizedTransactions.service';
import {
RecognizeUncategorizedTransactionsJobPayload,
RecognizeUncategorizedTransactionsQueue,
@@ -15,10 +16,12 @@ import {
export class RegonizeTransactionsPrcessor extends WorkerHost {
/**
* @param {RecognizeTranasctionsService} recognizeTranasctionsService -
* @param {RevertRecognizedTransactionsService} revertRecognizedTransactionsService -
* @param {ClsService} clsService -
*/
constructor(
private readonly recognizeTranasctionsService: RecognizeTranasctionsService,
private readonly revertRecognizedTransactionsService: RevertRecognizedTransactionsService,
private readonly clsService: ClsService,
) {
super();
@@ -29,12 +32,21 @@ export class RegonizeTransactionsPrcessor extends WorkerHost {
*/
@UseCls()
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('userId', job.data.userId);
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(
ruleId,
transactionsCriteria,

View File

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

View File

@@ -85,9 +85,14 @@ export class TaxRatesController {
status: 200,
description: 'The tax rates have been successfully retrieved.',
schema: {
type: 'array',
items: {
$ref: getSchemaPath(TaxRateResponseDto),
type: 'object',
properties: {
data: {
type: 'array',
items: {
$ref: getSchemaPath(TaxRateResponseDto),
},
},
},
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
// @ts-nocheck
import React from 'react';
import { Intent, Tag } from '@blueprintjs/core';
import { Intent, Tag, Classes } from '@blueprintjs/core';
import { Align } from '@/constants';
import styled from 'styled-components';
import clsx from 'classnames';
const codeAccessor = (taxRate) => {
return (
@@ -28,13 +28,17 @@ const nameAccessor = (taxRate) => {
return (
<>
<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) => {
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')`
line-height: 1;
font-size: 30px;
color: #565b71;
font-weight: 600;
display: inline-block;
color: var(--x-color-amount-text, #565b71);
.bp4-dark & {
color: rgba(255, 255, 255, 0.9);
}
`;
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_')),
email: Yup.string().email().nullable(),
work_phone: Yup.number(),
personal_phone: Yup.number(),
work_phone: Yup.string().nullable(),
personal_phone: Yup.string().nullable(),
website: Yup.string().url().nullable(),
active: Yup.boolean(),
@@ -23,7 +23,7 @@ const Schema = Yup.object().shape({
billing_address_city: Yup.string().trim(),
billing_address_state: Yup.string().trim(),
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_1: Yup.string().trim(),
@@ -31,7 +31,7 @@ const Schema = Yup.object().shape({
shipping_address_city: Yup.string().trim(),
shipping_address_state: Yup.string().trim(),
shipping_address_postcode: Yup.string().nullable(),
shipping_address_phone: Yup.number(),
shipping_address_phone: Yup.string().nullable(),
opening_balance: Yup.number().nullable(),
currency_code: Yup.string(),

View File

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