Compare commits

...

14 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
60f03f534b fix(subscription): event TS types 2024-08-24 21:51:15 +02:00
Ahmed Bouhuolia
8e94c7a755 feat(subscription): invalidate subscription cache 2024-08-24 21:40:28 +02:00
Ahmed Bouhuolia
3a2ca36c07 Merge branch 'develop' into listen-payment-webhooks 2024-08-24 21:08:47 +02:00
Ahmed Bouhuolia
88ece74c8a Merge pull request #622 from wolone/develop
Fix: Syntax error caused error
2024-08-24 20:48:14 +02:00
Ahmed Bouhuolia
67a8610328 feat: cancel/resume LS subscriptions 2024-08-24 20:46:30 +02:00
Ahmed Bouhuolia
278d61ce61 fix: Listen to payment webhooks 2024-08-24 18:50:12 +02:00
wolone
e72d6ad6b8 Fix: Syntax error caused error 2024-08-24 23:33:53 +08:00
Ahmed Bouhuolia
bf66b31679 Merge pull request #590 from bigcapitalhq/filter-uncategorized-bank-transactions
feat(banking): Filter uncategorized bank transactions by date
2024-08-23 17:42:59 +02:00
Ahmed Bouhuolia
b4d426d2e8 feat: Filter uncategorized transactions by date 2024-08-23 17:40:05 +02:00
Ahmed Bouhuolia
7f7dd270e7 Merge pull request #619 from bigcapitalhq/change-banking-service-language
feat: change banking service language
2024-08-23 02:00:47 +02:00
Ahmed Bouhuolia
f6bad8fe30 fix: wip filter uncategorized transactions by date 2024-08-23 01:57:52 +02:00
Ahmed Bouhuolia
1062b65b5b feat: wip bank account transactions date filter 2024-08-22 01:03:03 +02:00
Ahmed Bouhuolia
bf3a70cabd Merge branch 'develop' into filter-uncategorized-bank-transactions 2024-08-22 00:12:37 +02:00
Ahmed Bouhuolia
df8b68fda6 feat(banking): Filter uncategorized bank transactions 2024-08-11 18:34:45 +02:00
44 changed files with 946 additions and 83 deletions

View File

@@ -46,6 +46,10 @@ export class ExcludeBankTransactionsController extends BaseController {
query('account_id').optional().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
query('max_amount').optional({ nullable: true }).isFloat().toFloat(),
],
this.validationResult,
this.getExcludedBankTransactions.bind(this)

View File

@@ -21,6 +21,10 @@ export class RecognizedTransactionsController extends BaseController {
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
query('account_id').optional().isNumeric().toInt(),
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
query('max_amount').optional({ nullable: true }).isFloat().isFloat(),
],
this.validationResult,
this.getRecognizedTransactions.bind(this)

View File

@@ -84,6 +84,10 @@ export default class NewCashflowTransactionController extends BaseController {
param('id').exists().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(),
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
query('max_amount').optional({ nullable: true }).isFloat().toFloat(),
];
}

View File

@@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController {
const { tenantId } = req;
try {
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
await this.subscriptionApp.cancelSubscription(tenantId);
return res.status(200).send({
status: 200,

View File

@@ -35,7 +35,7 @@ export class Webhooks extends BaseController {
*/
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
const data = req.body;
const signature = req.headers['x-signature'] ?? '';
const signature = req.headers['x-signature'] as string ?? '';
const rawBody = req.rawBody;
try {

View File

@@ -167,11 +167,18 @@ export interface CategorizeTransactionAsExpenseDTO {
export interface IGetUncategorizedTransactionsQuery {
page?: number;
pageSize?: number;
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
}
export interface IGetRecognizedTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
}

View File

@@ -0,0 +1,8 @@
export interface SubscriptionPayload {
lemonSqueezyId?: string;
}
export enum SubscriptionPaymentStatus {
Succeed = 'succeed',
Failed = 'failed',
}

View File

@@ -75,6 +75,7 @@ export * from './Times';
export * from './ProjectProfitabilitySummary';
export * from './TaxRate';
export * from './Plaid';
export * from './Subscription';
export interface I18nService {
__: (input: string) => string;

View File

@@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
export default () => {
return new EventPublisher();
@@ -247,8 +248,10 @@ export const susbcribers = () => {
DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete,
// Subscription
SubscribeFreeOnSignupCommunity,
SendVerfiyMailOnSignUp,
TriggerInvalidateCacheOnSubscriptionChange,
// Attachments
AttachmentsOnSaleInvoiceCreated,
@@ -282,9 +285,9 @@ export const susbcribers = () => {
DeleteUncategorizedTransactionsOnAccountDeleting,
// Loops
LoopsEventsSubscriber
LoopsEventsSubscriber,
// Demo Account
SeedInitialDemoAccountDataOnOrgBuild
SeedInitialDemoAccountDataOnOrgBuild,
];
};

View File

@@ -1,8 +1,8 @@
/* eslint-disable global-require */
import moment from 'moment';
import { Model, mixin } from 'objection';
import TenantModel from 'models/TenantModel';
import ModelSettings from './ModelSetting';
import Account from './Account';
import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta';
export default class UncategorizedCashflowTransaction extends mixin(
@@ -166,6 +166,28 @@ export default class UncategorizedCashflowTransaction extends mixin(
pending(query) {
query.where('pending', true);
},
minAmount(query, minAmount) {
query.where('amount', '>=', minAmount);
},
maxAmount(query, maxAmount) {
query.where('amount', '<=', maxAmount);
},
toDate(query, toDate) {
const dateFormat = 'YYYY-MM-DD';
const _toDate = moment(toDate).endOf('day').format(dateFormat);
query.where('date', '<=', _toDate);
},
fromDate(query, fromDate) {
const dateFormat = 'YYYY-MM-DD';
const _fromDate = moment(fromDate).startOf('day').format(dateFormat);
query.where('date', '>=', _fromDate);
},
};
}

View File

@@ -1,5 +1,6 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import moment from 'moment';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ExcludedBankTransactionsQuery } from './_types';
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@@ -39,6 +40,18 @@ export class GetExcludedBankTransactionsService {
if (_query.accountId) {
q.where('account_id', _query.accountId);
}
if (_query.minDate) {
q.modify('fromDate', _query.minDate);
}
if (_query.maxDate) {
q.modify('toDate', _query.maxDate);
}
if (_query.minAmount) {
q.modify('minAmount', _query.minAmount);
}
if (_query.maxAmount) {
q.modify('maxAmount', _query.maxAmount);
}
})
.pagination(_query.page - 1, _query.pageSize);

View File

@@ -4,6 +4,10 @@ export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
}
export interface IBankTransactionUnexcludingEventPayload {

View File

@@ -23,7 +23,7 @@ export class GetRecognizedTransactionsService {
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const _filter = {
const _query = {
page: 1,
pageSize: 20,
...filter,
@@ -41,11 +41,26 @@ export class GetRecognizedTransactionsService {
// Exclude the pending transactions.
q.modify('notPending');
if (_filter.accountId) {
q.where('accountId', _filter.accountId);
if (_query.accountId) {
q.where('accountId', _query.accountId);
}
if (_query.minDate) {
q.modify('fromDate', _query.minDate);
}
if (_query.maxDate) {
q.modify('toDate', _query.maxDate);
}
if (_query.minAmount) {
q.modify('minAmount', _query.minAmount);
}
if (_query.maxAmount) {
q.modify('maxAmount', _query.maxAmount);
}
if (_query.accountId) {
q.where('accountId', _query.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
.pagination(_query.page - 1, _query.pageSize);
const data = await this.transformer.transform(
tenantId,

View File

@@ -62,6 +62,19 @@ export class GetUncategorizedTransactions {
q.whereNull('matchedBankTransactions.id');
q.orderBy('date', 'DESC');
if (_query.minDate) {
q.modify('fromDate', _query.minDate);
}
if (_query.maxDate) {
q.modify('toDate', _query.maxDate);
}
if (_query.minAmount) {
q.modify('minAmount', _query.minAmount);
}
if (_query.maxAmount) {
q.modify('maxAmount', _query.maxAmount);
}
})
.pagination(_query.page - 1, _query.pageSize);

View File

@@ -4,8 +4,8 @@ import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ERRORS, IOrganizationSubscriptionCancel } from './types';
import events from '@/subscribers/events';
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
@Service()
export class LemonCancelSubscription {
@@ -18,12 +18,15 @@ export class LemonCancelSubscription {
* @param {number} subscriptionId
* @returns {Promise<void>}
*/
public async cancelSubscription(tenantId: number) {
public async cancelSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
@@ -35,13 +38,10 @@ export class LemonCancelSubscription {
if (cancelledSub.error) {
throw new Error(cancelledSub.error.message);
}
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: new Date(),
});
// Triggers `onSubscriptionCanceled` event.
// Triggers `onSubscriptionCancelled` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionCanceled,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled
events.subscription.onSubscriptionCancel,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCancel
);
}
}

View File

@@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan {
* @param {number} newVariantId - New variant id.
* @returns {Promise<void>}
*/
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) {
public async changeSubscriptionPlan(
tenantId: number,
newVariantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
const lemonSubscriptionId = subscription.lemonSubscriptionId;
// Send request to Lemon Squeezy to change the subscription.
const updatedSub = await updateSubscription(lemonSubscriptionId, {
variantId: newVariantId,
invoiceImmediately: true,
});
if (updatedSub.error) {
throw new ServiceError('SOMETHING_WENT_WRONG');
}
// Triggers `onSubscriptionPlanChanged` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPlanChanged,
events.subscription.onSubscriptionPlanChange,
{
tenantId,
lemonSubscriptionId,

View File

@@ -1,11 +1,11 @@
import { Inject, Service } from 'typedi';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';
import { ERRORS, IOrganizationSubscriptionResumed } from './types';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { ERRORS, IOrganizationSubscriptionResume } from './types';
@Service()
export class LemonResumeSubscription {
@@ -14,15 +14,16 @@ export class LemonResumeSubscription {
/**
* Resumes the main subscription of the given tenant.
* @param {number} tenantId -
* @param {number} tenantId - Tenant id.
* @param {string} subscriptionSlug - Subscription slug by default main subscription.
* @returns {Promise<void>}
*/
public async resumeSubscription(tenantId: number) {
public async resumeSubscription(tenantId: number, subscriptionSlug: string = 'main') {
configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
@@ -33,16 +34,12 @@ export class LemonResumeSubscription {
cancelled: false,
});
if (returnedSub.error) {
throw new ServiceError('');
throw new ServiceError(ٌٌُERRORS.SOMETHING_WENT_WRONG_WITH_LS);
}
// Update the subscription of the organization.
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: null,
});
// Triggers `onSubscriptionCanceled` event.
// Triggers `onSubscriptionResume` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionResumed,
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
events.subscription.onSubscriptionResume,
{ tenantId, subscriptionId } as IOrganizationSubscriptionResume
);
}
}

View File

@@ -59,11 +59,25 @@ export class LemonSqueezyWebhooks {
const userId = eventBody.meta.custom_data?.user_id;
const tenantId = eventBody.meta.custom_data?.tenant_id;
const subscriptionSlug = 'main';
if (!webhookHasMeta(eventBody)) {
throw new Error("Event body is missing the 'meta' property.");
} else if (webhookHasData(eventBody)) {
if (webhookEvent.startsWith('subscription_payment_')) {
// Marks the main subscription payment as succeed.
if (webhookEvent === 'subscription_payment_success') {
await this.subscriptionService.markSubscriptionPaymentSucceed(
tenantId,
subscriptionSlug
);
// Marks the main subscription payment as failed.
} else if (webhookEvent === 'subscription_payment_failed') {
await this.subscriptionService.markSubscriptionPaymentFailed(
tenantId,
subscriptionSlug
);
}
// Save subscription invoices; eventBody is a SubscriptionInvoice
// Not implemented.
} else if (webhookEvent.startsWith('subscription_')) {
@@ -74,16 +88,39 @@ export class LemonSqueezyWebhooks {
// We assume that the Plan table is up to date.
const plan = await Plan.query().findOne('lemonVariantId', variantId);
// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;
const subscriptionId = eventBody.data.id;
// Throw error early if the given lemon variant id is not associated to any plan.
if (!plan) {
throw new Error(`Plan with variantId ${variantId} not found.`);
} else {
// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;
// Create a new subscription of the tenant.
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
}
}
// Create a new subscription of the tenant.
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(
tenantId,
plan.slug,
subscriptionSlug,
{ lemonSqueezyId: subscriptionId }
);
// Cancel the given subscription of the organization.
} else if (webhookEvent === 'subscription_cancelled') {
await this.subscriptionService.cancelSubscription(
tenantId,
subscriptionSlug
);
} else if (webhookEvent === 'subscription_plan_changed') {
await this.subscriptionService.subscriptionPlanChanged(
tenantId,
plan.slug,
subscriptionSlug
);
} else if (webhookEvent === 'subscription_resumed') {
await this.subscriptionService.resumeSubscription(
tenantId,
subscriptionSlug
);
}
} else if (webhookEvent.startsWith('order_')) {
// Save orders; eventBody is a "Order"

View File

@@ -1,22 +1,29 @@
import { Service } from 'typedi';
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
import { Plan, Tenant } from '@/system/models';
import { Inject, Service } from 'typedi';
import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions';
import { Plan, PlanSubscription, Tenant } from '@/system/models';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { SubscriptionPayload, SubscriptionPaymentStatus } from '@/interfaces';
import { ERRORS } from './types';
@Service()
export class Subscription {
@Inject()
private eventPublisher: EventPublisher;
/**
* Give the tenant a new subscription.
* @param {number} tenantId - Tenant id.
* @param {string} planSlug - Plan slug.
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} subscriptionSlug
* @param {string} planSlug - Plan slug of the new subscription.
* @param {string} subscriptionSlug - Subscription slug by default takes main subscription
* @param {SubscriptionPayload} payload - Subscription payload.
*/
public async newSubscribtion(
tenantId: number,
planSlug: string,
subscriptionSlug: string = 'main'
) {
subscriptionSlug: string = 'main',
payload?: SubscriptionPayload
): Promise<void> {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
@@ -45,8 +52,169 @@ export class Subscription {
plan.id,
invoiceInterval,
invoicePeriod,
subscriptionSlug
subscriptionSlug,
payload
);
}
}
/**
* Cancels the given tenant subscription.
* @param {number} tenantId - Tenant id.
* @param {string} subscriptionSlug - Subscription slug.
*/
async cancelSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
): Promise<void> {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: subscriptionSlug,
});
// Throw error early if the subscription is not exist.
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST);
}
// Throw error early if the subscription is already canceled.
if (subscription.canceled()) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED);
}
await subscription.$query().patch({ canceledAt: new Date() });
// Triggers `onSubscriptionCancelled` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionCancelled,
{
tenantId,
subscriptionSlug,
}
);
}
/**
* Resumes the given tenant subscription.
* @param {number} tenantId
* @param {string} subscriptionSlug - Subscription slug by deafult main subscription.
* @returns {Promise<void>}
*/
async resumeSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: subscriptionSlug,
});
// Throw error early if the subscription is not exist.
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST);
}
// Throw error early if the subscription is not cancelled.
if (!subscription.canceled()) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_ACTIVE);
}
await subscription.$query().patch({ canceledAt: null });
// Triggers `onSubscriptionResumed` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionResumed,
{ tenantId, subscriptionSlug }
);
}
/**
* Mark the given subscription payment of the tenant as succeed.
* @param {number} tenantId
* @param {string} newPlanSlug
* @param {string} subscriptionSlug
*/
async subscriptionPlanChanged(
tenantId: number,
newPlanSlug: string,
subscriptionSlug: string = 'main'
): Promise<void> {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const newPlan = await Plan.query()
.findOne('slug', newPlanSlug)
.throwIfNotFound();
const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: subscriptionSlug,
});
if (subscription.planId === newPlan.id) {
throw new ServiceError('');
}
await subscription.$query().patch({ planId: newPlan.id });
// Triggers `onSubscriptionPlanChanged` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPlanChanged,
{
tenantId,
newPlanSlug,
subscriptionSlug,
}
);
}
/**
* Marks the subscription payment as succeed.
* @param {number} tenantId - Tenant id.
* @param {string} subscriptionSlug - Given subscription slug by default main subscription.
* @returns {Promise<void>}
*/
async markSubscriptionPaymentSucceed(
tenantId: number,
subscriptionSlug: string = 'main'
): Promise<void> {
const subscription = await PlanSubscription.query()
.findOne({ tenantId, slug: subscriptionSlug })
.throwIfNotFound();
await subscription
.$query()
.patch({ paymentStatus: SubscriptionPaymentStatus.Succeed });
// Triggers `onSubscriptionSucceed` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPaymentSucceed,
{
tenantId,
subscriptionSlug,
}
);
}
/**
* Marks the given subscription payment of the tenant as failed.
* @param {number} tenantId - Tenant id.
* @param {string} subscriptionSlug - Given subscription slug.
* @returns {Prmise<void>}
*/
async markSubscriptionPaymentFailed(
tenantId: number,
subscriptionSlug: string = 'main'
): Promise<void> {
const subscription = await PlanSubscription.query()
.findOne({ tenantId, slug: subscriptionSlug })
.throwIfNotFound();
await subscription
.$query()
.patch({ paymentStatus: SubscriptionPaymentStatus.Failed });
// Triggers `onSubscriptionPaymentFailed` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPaymentFailed,
{
tenantId,
subscriptionSlug,
}
);
}
}

View File

@@ -20,8 +20,14 @@ export class SubscriptionApplication {
* @param {string} id
* @returns {Promise<void>}
*/
public cancelSubscription(tenantId: number, id: string) {
return this.cancelSubscriptionService.cancelSubscription(tenantId, id);
public cancelSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
return this.cancelSubscriptionService.cancelSubscription(
tenantId,
subscriptionSlug
);
}
/**
@@ -29,8 +35,14 @@ export class SubscriptionApplication {
* @param {number} tenantId
* @returns {Promise<void>}
*/
public resumeSubscription(tenantId: number) {
return this.resumeSubscriptionService.resumeSubscription(tenantId);
public resumeSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
return this.resumeSubscriptionService.resumeSubscription(
tenantId,
subscriptionSlug
);
}
/**

View File

@@ -1,8 +1,8 @@
import { Inject, Service } from 'typedi';
import { IAuthSignedUpEventPayload } from '@/interfaces';
import events from '@/subscribers/events';
import config from '@/config';
import { Subscription } from '../Subscription';
import { Inject, Service } from 'typedi';
@Service()
export class SubscribeFreeOnSignupCommunity {

View File

@@ -0,0 +1,29 @@
import events from '@/subscribers/events';
import Container from 'typedi';
export class TriggerInvalidateCacheOnSubscriptionChange {
/**
* Attaches events with handlers.
*/
public attach = (bus) => {
bus.subscribe(
events.subscription.onSubscriptionCancelled,
this.triggerInvalidateCache.bind(this)
);
bus.subscribe(
events.subscription.onSubscriptionResumed,
this.triggerInvalidateCache.bind(this)
);
bus.subscribe(
events.subscription.onSubscriptionPlanChanged,
this.triggerInvalidateCache.bind(this)
);
};
private triggerInvalidateCache() {
const io = Container.get('socket');
// Notify the frontend to reflect the new transactions changes.
io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' });
}
}

View File

@@ -1,6 +1,10 @@
export const ERRORS = {
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT',
SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST',
SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED',
SUBSCRIPTION_ALREADY_ACTIVE: 'SUBSCRIPTION_ALREADY_ACTIVE',
SOMETHING_WENT_WRONG_WITH_LS: 'SOMETHING_WENT_WRONG_WITH_LS',
};
export interface IOrganizationSubscriptionChanged {
@@ -9,11 +13,20 @@ export interface IOrganizationSubscriptionChanged {
newVariantId: number;
}
export interface IOrganizationSubscriptionCanceled {
export interface IOrganizationSubscriptionCancel {
tenantId: number;
subscriptionId: string;
}
export interface IOrganizationSubscriptionCancelled {
tenantId: number;
subscriptionId: string;
}
export interface IOrganizationSubscriptionResume {
tenantId: number;
subscriptionId: number;
}
export interface IOrganizationSubscriptionResumed {
tenantId: number;
subscriptionId: number;

View File

@@ -46,10 +46,19 @@ export default {
* Organization subscription.
*/
subscription: {
onSubscriptionCanceled: 'onSubscriptionCanceled',
onSubscriptionCancel: 'onSubscriptionCancel',
onSubscriptionCancelled: 'onSubscriptionCancelled',
onSubscriptionResume: 'onSubscriptionResume',
onSubscriptionResumed: 'onSubscriptionResumed',
onSubscriptionPlanChange: 'onSubscriptionPlanChange',
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
onSubscribed: 'onOrganizationSubscribed',
onSubscriptionSubscribed: 'onSubscriptionSubscribed',
onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed',
onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed'
},
/**

View File

@@ -0,0 +1,19 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.string('payment_status');
});
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.table('subscription_plan_subscriptions', (table) => {
table.dropColumn('payment_status');
});
};

View File

@@ -3,6 +3,10 @@ import SystemModel from '@/system/models/SystemModel';
import { PlanSubscription } from '..';
export default class Plan extends mixin(SystemModel) {
price: number;
invoiceInternal: number;
invoicePeriod: string;
/**
* Table name.
*/

View File

@@ -198,14 +198,16 @@ export default class Tenant extends BaseModel {
planId,
invoiceInterval,
invoicePeriod,
subscriptionSlug
subscriptionSlug,
payload?,
) {
return Tenant.newSubscription(
this.id,
planId,
invoiceInterval,
invoicePeriod,
subscriptionSlug
subscriptionSlug,
payload
);
}
@@ -217,7 +219,8 @@ export default class Tenant extends BaseModel {
planId: number,
invoiceInterval: 'month' | 'year',
invoicePeriod: number,
subscriptionSlug: string
subscriptionSlug: string,
payload?: { lemonSqueezyId: string }
) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
@@ -227,6 +230,7 @@ export default class Tenant extends BaseModel {
planId,
startsAt: period.getStartDate(),
endsAt: period.getEndDate(),
lemonSubscriptionId: payload?.lemonSqueezyId || null,
});
}
}

View File

@@ -23,6 +23,9 @@ export function DashboardSockets() {
intent: Intent.SUCCESS,
});
});
socket.current.on('SUBSCRIPTION_CHANGED', () => {
client.invalidateQueries('GetSubscriptions');
});
return () => {
socket.current.removeAllListeners();
socket.current.close();

View File

@@ -2,7 +2,6 @@
display: flex;
flex-direction: row;
gap: 10px;
margin-bottom: 14px;
}
.tag{

View File

@@ -0,0 +1,5 @@
.dateFieldGroup{
margin-bottom: 0;
}

View File

@@ -0,0 +1,235 @@
// @ts-nocheck
import { Button, FormGroup, Intent, Position } from '@blueprintjs/core';
import * as Yup from 'yup';
import moment from 'moment';
import { Form, Formik, FormikConfig, useFormikContext } from 'formik';
import {
FDateInput,
FFormGroup,
FSelect,
Group,
Icon,
Stack,
} from '@/components';
const defaultValues = {
period: 'all_dates',
fromDate: '',
toDate: '',
};
const validationSchema = Yup.object().shape({
fromDate: Yup.date()
.nullable()
.required('From Date is required')
.max(Yup.ref('toDate'), 'From Date cannot be after To Date'),
toDate: Yup.date()
.nullable()
.required('To Date is required')
.min(Yup.ref('fromDate'), 'To Date cannot be before From Date'),
});
interface AccountTransactionsDateFilterFormValues {
period: string;
fromDate: string;
toDate: string;
}
interface UncategorizedTransactionsDateFilterProps {
initialValues?: AccountTransactionsDateFilterFormValues;
onSubmit?: FormikConfig<AccountTransactionsDateFilterFormValues>['onSubmit'];
}
export function AccountTransactionsDateFilterForm({
initialValues = {},
onSubmit,
}: UncategorizedTransactionsDateFilterProps) {
const handleSubmit = (values, bag) => {
return onSubmit && onSubmit(values, bag);
};
const formInitialValues = {
...defaultValues,
...initialValues,
};
return (
<Formik
initialValues={formInitialValues}
onSubmit={handleSubmit}
validationSchema={validationSchema}
>
<Form>
<Stack spacing={15}>
<Group spacing={10}>
<AccountTransactionDatePeriodField />
<FFormGroup
name={'fromDate'}
label={'From Date'}
style={{ marginBottom: 0, flex: '1' }}
>
<FDateInput
name={'fromDate'}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{
fill: true,
placeholder: 'MM/DD/YYY',
leftElement: <Icon icon={'date-range'} />,
}}
/>
</FFormGroup>
<FormGroup
label={'To Date'}
name={'toDate'}
style={{ marginBottom: 0, flex: '1' }}
>
<FDateInput
name={'toDate'}
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{
fill: true,
placeholder: 'MM/DD/YYY',
leftElement: <Icon icon={'date-range'} />,
}}
/>
</FormGroup>
</Group>
<AccountTransactionsDateFilterFooter />
</Stack>
</Form>
</Formik>
);
}
function AccountTransactionsDateFilterFooter() {
const { submitForm, setValues } = useFormikContext();
const handleFilterBtnClick = () => {
submitForm();
};
const handleClearBtnClick = () => {
setValues({
...defaultValues,
});
submitForm();
};
return (
<Group spacing={10}>
<Button
small
intent={Intent.PRIMARY}
onClick={handleFilterBtnClick}
style={{ minWidth: 75 }}
>
Filter
</Button>
<Button
intent={Intent.DANGER}
small
onClick={handleClearBtnClick}
minimal
>
Clear
</Button>
</Group>
);
}
function AccountTransactionDatePeriodField() {
const { setFieldValue } = useFormikContext();
const handleItemChange = (item) => {
const { fromDate, toDate } = getDateRangePeriod(item.value);
setFieldValue('fromDate', fromDate);
setFieldValue('toDate', toDate);
setFieldValue('period', item.value);
};
return (
<FFormGroup
name={'period'}
label={'Date'}
style={{ marginBottom: 0, flex: '0 28%' }}
>
<FSelect
name={'period'}
items={periodOptions}
onItemSelect={handleItemChange}
popoverProps={{ captureDismiss: true }}
/>
</FFormGroup>
);
}
const periodOptions = [
{ text: 'All Dates', value: 'all_dates' },
{ text: 'Custom', value: 'custom' },
{ text: 'Today', value: 'today' },
{ text: 'Yesterday', value: 'yesterday' },
{ text: 'This week', value: 'this_week' },
{ text: 'This year', value: 'this_year' },
{ text: 'This month', value: 'this_month' },
{ text: 'last week', value: 'last_week' },
{ text: 'Last year', value: 'last_year' },
{ text: 'Last month', value: 'last_month' },
{ text: 'Last month', value: 'last_month' },
];
const getDateRangePeriod = (period: string) => {
switch (period) {
case 'today':
return {
fromDate: moment().startOf('day').toDate(),
toDate: moment().endOf('day').toDate(),
};
case 'yesterday':
return {
fromDate: moment().subtract(1, 'days').startOf('day').toDate(),
toDate: moment().subtract(1, 'days').endOf('day').toDate(),
};
case 'this_week':
return {
fromDate: moment().startOf('week').toDate(),
toDate: moment().endOf('week').toDate(),
};
case 'this_month':
return {
fromDate: moment().startOf('month').toDate(),
toDate: moment().endOf('month').toDate(),
};
case 'this_year':
return {
fromDate: moment().startOf('year').toDate(),
toDate: moment().endOf('year').toDate(),
};
case 'last_week':
return {
fromDate: moment().subtract(1, 'weeks').startOf('week').toDate(),
toDate: moment().subtract(1, 'weeks').endOf('week').toDate(),
};
case 'last_month':
return {
fromDate: moment().subtract(1, 'months').startOf('month').toDate(),
toDate: moment().subtract(1, 'months').endOf('month').toDate(),
};
case 'last_year':
return {
fromDate: moment().subtract(1, 'years').startOf('year').toDate(),
toDate: moment().subtract(1, 'years').endOf('year').toDate(),
};
case 'all_dates':
case 'custom':
default:
return { fromDate: null, toDate: null };
}
};

View File

@@ -1,10 +1,12 @@
// @ts-nocheck
import * as R from 'ramda';
import { useMemo } from 'react';
import * as R from 'ramda';
import { useAppQueryString } from '@/hooks';
import { Group } from '@/components';
import { Group, Stack, } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { TagsControl } from '@/components/TagsControl';
import { AccountUncategorizedDateFilter } from './UncategorizedTransactions/AccountUncategorizedDateFilter';
import { Divider } from '@blueprintjs/core';
export function AccountTransactionsUncategorizeFilter() {
const { bankAccountMetaSummary } = useAccountTransactionsContext();
@@ -54,12 +56,17 @@ export function AccountTransactionsUncategorizeFilter() {
);
return (
<Group position={'apart'}>
<TagsControl
options={options}
value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange}
/>
<Group position={'apart'} style={{ marginBottom: 14 }}>
<Group align={'stretch'} spacing={10}>
<TagsControl
options={options}
value={locationQuery?.uncategorizedFilter || 'all'}
onValueChange={handleTabsChange}
/>
<Divider />
<AccountUncategorizedDateFilter />
</Group>
<TagsControl
options={[{ value: 'excluded', label: 'Excluded' }]}
value={locationQuery?.uncategorizedFilter || 'all'}

View File

@@ -2,9 +2,11 @@
import React from 'react';
import { flatten, map } from 'lodash';
import * as R from 'ramda';
import { IntersectionObserver } from '@/components';
import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { withBanking } from '../withBanking';
const AccountUncategorizedTransactionsContext = React.createContext();
@@ -13,9 +15,15 @@ function flattenInfinityPagesData(data) {
}
/**
* Account uncategorized transctions provider.
* Account un-categorized transactions provider.
*/
function AccountUncategorizedTransactionsBoot({ children }) {
function AccountUncategorizedTransactionsBootRoot({
// #withBanking
uncategorizedTransactionsFilter,
// #ownProps
children,
}) {
const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions.
@@ -29,6 +37,8 @@ function AccountUncategorizedTransactionsBoot({ children }) {
hasNextPage: hasUncategorizedTransactionsNextPage,
} = useAccountUncategorizedTransactionsInfinity(accountId, {
page_size: 50,
min_date: uncategorizedTransactionsFilter?.fromDate || null,
max_date: uncategorizedTransactionsFilter?.toDate || null,
});
// Memorized the cashflow account transactions.
const uncategorizedTransactions = React.useMemo(
@@ -69,6 +79,12 @@ function AccountUncategorizedTransactionsBoot({ children }) {
);
}
const AccountUncategorizedTransactionsBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(AccountUncategorizedTransactionsBootRoot);
const useAccountUncategorizedTransactionsContext = () =>
React.useContext(AccountUncategorizedTransactionsContext);

View File

@@ -1,9 +1,11 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import * as R from 'ramda';
import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
import { withBanking } from '../../withBanking';
interface ExcludedBankTransactionsContextValue {
isExcludedTransactionsLoading: boolean;
@@ -27,7 +29,11 @@ interface ExcludedBankTransactionsTableBootProps {
/**
* Account uncategorized transctions provider.
*/
function ExcludedBankTransactionsTableBoot({
function ExcludedBankTransactionsTableBootRoot({
// #withBanking
uncategorizedTransactionsFilter,
// #ownProps
children,
}: ExcludedBankTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
@@ -44,6 +50,8 @@ function ExcludedBankTransactionsTableBoot({
} = useExcludedBankTransactionsInfinity({
page_size: 50,
account_id: accountId,
min_date: uncategorizedTransactionsFilter?.fromDate || null,
max_date: uncategorizedTransactionsFilter.toDate || null,
});
// Memorized the cashflow account transactions.
const excludedBankTransactions = React.useMemo(
@@ -84,6 +92,12 @@ function ExcludedBankTransactionsTableBoot({
);
}
const ExcludedBankTransactionsTableBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(ExcludedBankTransactionsTableBootRoot);
const useExcludedTransactionsBoot = () =>
React.useContext(ExcludedTransactionsContext);

View File

@@ -1,9 +1,11 @@
// @ts-nocheck
import React from 'react';
import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components';
import * as R from 'ramda';
import { IntersectionObserver, NumericInputCell } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
import { withBanking } from '../../withBanking';
interface RecognizedTransactionsContextValue {
isRecongizedTransactionsLoading: boolean;
@@ -27,7 +29,10 @@ interface RecognizedTransactionsTableBootProps {
/**
* Account uncategorized transctions provider.
*/
function RecognizedTransactionsTableBoot({
function RecognizedTransactionsTableBootRoot({
// #withBanking
uncategorizedTransactionsFilter,
children,
}: RecognizedTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext();
@@ -44,6 +49,8 @@ function RecognizedTransactionsTableBoot({
} = useRecognizedBankTransactionsInfinity({
page_size: 50,
account_id: accountId,
min_date: uncategorizedTransactionsFilter.fromDate || null,
max_date: uncategorizedTransactionsFilter?.toDate || null,
});
// Memorized the cashflow account transactions.
const recognizedTransactions = React.useMemo(
@@ -84,6 +91,12 @@ function RecognizedTransactionsTableBoot({
);
}
const RecognizedTransactionsTableBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(RecognizedTransactionsTableBootRoot);
const useRecognizedTransactionsBoot = () =>
React.useContext(RecognizedTransactionsContext);

View File

@@ -0,0 +1,105 @@
// @ts-nocheck
import { useState } from 'react';
import * as R from 'ramda';
import moment from 'moment';
import { Box, Icon } from '@/components';
import { Classes, Popover, Position } from '@blueprintjs/core';
import { withBankingActions } from '../../withBankingActions';
import { withBanking } from '../../withBanking';
import { AccountTransactionsDateFilterForm } from '../AccountTransactionsDateFilter';
import { TagButton } from './TagButton';
function AccountUncategorizedDateFilterRoot({
uncategorizedTransactionsFilter,
}) {
const fromDate = uncategorizedTransactionsFilter?.fromDate;
const toDate = uncategorizedTransactionsFilter?.toDate;
const fromDateFormatted = moment(fromDate).isSame(
moment().format('YYYY'),
'year',
)
? moment(fromDate).format('MMM, DD')
: moment(fromDate).format('MMM, DD, YYYY');
const toDateFormatted = moment(toDate).isSame(moment().format('YYYY'), 'year')
? moment(toDate).format('MMM, DD')
: moment(toDate).format('MMM, DD, YYYY');
const buttonText =
fromDate && toDate
? `Date: ${fromDateFormatted}${toDateFormatted}`
: 'Date Filter';
// Popover open state.
const [isOpen, setIsOpen] = useState<boolean>(false);
// Handle the filter form submitting.
const handleSubmit = () => {
setIsOpen(false);
};
return (
<Popover
content={
<Box style={{ padding: 18 }}>
<UncategorizedTransactionsDateFilter onSubmit={handleSubmit} />
</Box>
}
position={Position.RIGHT}
popoverClassName={Classes.POPOVER_CONTENT}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<TagButton
outline
icon={<Icon icon={'date-range'} />}
onClick={() => setIsOpen(!isOpen)}
>
{buttonText}
</TagButton>
</Popover>
);
}
export const AccountUncategorizedDateFilter = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(AccountUncategorizedDateFilterRoot);
export const UncategorizedTransactionsDateFilter = R.compose(
withBankingActions,
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(
({
// #withBankingActions
setUncategorizedTransactionsFilter,
// #withBanking
uncategorizedTransactionsFilter,
// #ownProps
onSubmit,
}) => {
const initialValues = {
...uncategorizedTransactionsFilter,
};
const handleSubmit = (values) => {
setUncategorizedTransactionsFilter({
fromDate: values.fromDate,
toDate: values.toDate,
});
onSubmit && onSubmit(values);
};
return (
<AccountTransactionsDateFilterForm
initialValues={initialValues}
onSubmit={handleSubmit}
/>
);
},
);

View File

@@ -0,0 +1,11 @@
.root{
min-height: 26px;
border-radius: 15px;
font-size: 13px;
padding: 0 10px;
&:global(.bp4-button:not([class*=bp4-intent-]):not(.bp4-minimal)) {
background: #fff;
border: 1px solid #e1e2e8;
}
}

View File

@@ -0,0 +1,9 @@
// @ts-nocheck
import { Button } from "@blueprintjs/core"
import styles from './TagButton.module.scss';
export const TagButton = (props) => {
return <Button {...props} className={styles.root} />
}

View File

@@ -25,6 +25,8 @@ export const withBanking = (mapState) => {
categorizedTransactionsSelected:
state.plaid.categorizedTransactionsSelected,
uncategorizedTransactionsFilter: state.plaid.uncategorizedFilter
};
return mapState ? mapState(mapped, state, props) : mapped;
};

View File

@@ -15,6 +15,8 @@ import {
removeTransactionsToCategorizeSelected,
setCategorizedTransactionsSelected,
resetCategorizedTransactionsSelected,
setUncategorizedTransactionsFilter,
resetUncategorizedTranasctionsFilter,
} from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps {
@@ -40,6 +42,9 @@ export interface WithBankingActionsProps {
setCategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetCategorizedTransactionsSelected: () => void;
setUncategorizedTransactionsFilter: (filter: any) => void;
resetUncategorizedTranasctionsFilter: () => void;
}
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -138,6 +143,19 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
*/
resetCategorizedTransactionsSelected: () =>
dispatch(resetCategorizedTransactionsSelected()),
/**
* Sets the uncategorized transactions filter.
* @param {any} filter -
*/
setUncategorizedTransactionsFilter: (filter: any) =>
dispatch(setUncategorizedTransactionsFilter({ filter })),
/**
* Resets the uncategorized transactions filter.
*/
resetUncategorizedTranasctionsFilter: () =>
dispatch(resetUncategorizedTranasctionsFilter()),
});
export const withBankingActions = connect<

View File

@@ -152,7 +152,7 @@ export function useAccountUncategorizedTransactionsInfinity(
const apiRequest = useApiRequest();
return useInfiniteQuery(
[t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId],
[t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId, query],
async ({ pageParam = 1 }) => {
const response = await apiRequest.http({
...axios,

View File

@@ -12,7 +12,7 @@ import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils';
const QueryKeys = {
Subscriptions: 'Subscriptions',
Subscriptions: 'GetSubscriptions',
};
interface CancelMainSubscriptionValues {}

View File

@@ -14,6 +14,8 @@ interface StorePlaidState {
enableMultipleCategorization: boolean;
categorizedTransactionsSelected: Array<number | string>;
uncategorizedFilter: { fromDate?: string; toDate?: string };
}
export const PlaidSlice = createSlice({
@@ -31,6 +33,9 @@ export const PlaidSlice = createSlice({
transactionsToCategorizeSelected: [],
enableMultipleCategorization: false,
categorizedTransactionsSelected: [],
// Filter
uncategorizedFilter: {},
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -199,6 +204,26 @@ export const PlaidSlice = createSlice({
resetCategorizedTransactionsSelected: (state: StorePlaidState) => {
state.categorizedTransactionsSelected = [];
},
/**
* Sets the uncategorized transactions filter.
* @param {StorePlaidState} state
* @param {PayloadAction<{ filter: any }>} action
*/
setUncategorizedTransactionsFilter: (
state: StorePlaidState,
action: PayloadAction<{ filter: any }>,
) => {
state.uncategorizedFilter = action.payload.filter;
},
/**
* Resets the uncategorized transactions filter.
* @param {StorePlaidState} state
*/
resetUncategorizedTranasctionsFilter: (state: StorePlaidState) => {
state.uncategorizedFilter = {};
},
},
});
@@ -220,6 +245,10 @@ export const {
enableMultipleCategorization,
setCategorizedTransactionsSelected,
resetCategorizedTransactionsSelected,
// Uncategorized transactions filter.
setUncategorizedTransactionsFilter,
resetUncategorizedTranasctionsFilter,
} = PlaidSlice.actions;
export const getPlaidToken = (state: any) => state.plaid.plaidToken;
@@ -234,3 +263,6 @@ export const isMultipleCategorization = (state: any) =>
export const getTransactionsToCategorizeIdsSelected = (state: any) =>
state.plaid.transactionsToCategorizeSelected;
export const getUncategorizedTransactionsFilter = (state: any) =>
state.uncategorizedFilter;

View File

@@ -17,7 +17,6 @@
&.bp4-small {
font-size: 13px;
min-height: 29px;
}
}