@actual-app/core 26.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.swcrc +11 -0
- package/bin/build-browser +40 -0
- package/bin/copy-migrations +9 -0
- package/db.sqlite +0 -0
- package/default-db.sqlite +0 -0
- package/migrations/.force-copy-windows +0 -0
- package/migrations/1548957970627_remove-db-version.sql +5 -0
- package/migrations/1550601598648_payees.sql +23 -0
- package/migrations/1555786194328_remove_category_group_unique.sql +25 -0
- package/migrations/1561751833510_indexes.sql +7 -0
- package/migrations/1567699552727_budget.sql +38 -0
- package/migrations/1582384163573_cleared.sql +6 -0
- package/migrations/1597756566448_rules.sql +10 -0
- package/migrations/1608652596043_parent_field.sql +13 -0
- package/migrations/1608652596044_trans_views.sql +56 -0
- package/migrations/1612625548236_optimize.sql +7 -0
- package/migrations/1614782639336_trans_views2.sql +33 -0
- package/migrations/1615745967948_meta.sql +10 -0
- package/migrations/1616167010796_accounts_order.sql +5 -0
- package/migrations/1618975177358_schedules.sql +28 -0
- package/migrations/1632571489012_remove_cache.js +136 -0
- package/migrations/1679728867040_rules_conditions.sql +5 -0
- package/migrations/1681115033845_add_schedule_name.sql +5 -0
- package/migrations/1682974838138_remove_payee_rules.sql +5 -0
- package/migrations/1685007876842_add_category_hidden.sql +6 -0
- package/migrations/1686139660866_remove_account_type.sql +5 -0
- package/migrations/1688749527273_transaction_filters.sql +10 -0
- package/migrations/1688841238000_add_account_type.sql +5 -0
- package/migrations/1691233396000_add_schedule_next_date_tombstone.sql +5 -0
- package/migrations/1694438752000_add_goal_targets.sql +7 -0
- package/migrations/1697046240000_add_reconciled.sql +5 -0
- package/migrations/1704572023730_add_account_sync_source.sql +5 -0
- package/migrations/1704572023731_add_missing_goCardless_sync_source.sql +9 -0
- package/migrations/1707267033000_reports.sql +28 -0
- package/migrations/1712784523000_unhide_input_group.sql +8 -0
- package/migrations/1716359441000_include_current.sql +5 -0
- package/migrations/1720310586000_link_transfer_schedules.sql +19 -0
- package/migrations/1720664867241_add_payee_favorite.sql +5 -0
- package/migrations/1720665000000_goal_context.sql +6 -0
- package/migrations/1722717601000_reports_move_selected_categories.js +55 -0
- package/migrations/1722804019000_create_dashboard_table.js +69 -0
- package/migrations/1723665565000_prefs.js +59 -0
- package/migrations/1730744182000_fix_dashboard_table.sql +7 -0
- package/migrations/1736640000000_custom_report_sorting.sql +7 -0
- package/migrations/1737158400000_add_learn_categories_to_payees.sql +5 -0
- package/migrations/1738491452000_sorting_rename.sql +13 -0
- package/migrations/1739139550000_bank_sync_page.sql +7 -0
- package/migrations/1740506588539_add_last_reconciled_at.sql +5 -0
- package/migrations/1745425408000_update_budgetType_pref.sql +7 -0
- package/migrations/1749799110000_add_tags.sql +10 -0
- package/migrations/1749799110001_tags_tombstone.sql +5 -0
- package/migrations/1754611200000_add_category_template_settings.sql +5 -0
- package/migrations/1759260219000_add_trim_interval_report_setting.sql +6 -0
- package/migrations/1759842823172_add_isGlobal_to_preferences.sql +1 -0
- package/migrations/1762178745667_rename_csv_skip_lines_pref.sql +8 -0
- package/migrations/1765518577215_multiple_dashboards.js +30 -0
- package/migrations/1768872504000_add_payee_locations.sql +21 -0
- package/package.json +128 -0
- package/src/mocks/arbitrary-schema.ts +162 -0
- package/src/mocks/budget.ts +901 -0
- package/src/mocks/files/8859-1.qfx +63 -0
- package/src/mocks/files/best.data-ever$.QFX +124 -0
- package/src/mocks/files/big.data.QiF +91 -0
- package/src/mocks/files/budgets/.commit-to-git +0 -0
- package/src/mocks/files/camt/camt.053.payee-memo.xml +127 -0
- package/src/mocks/files/camt/camt.053.xml +463 -0
- package/src/mocks/files/credit-card.ofx +11 -0
- package/src/mocks/files/data-multi-decimal.ofx +64 -0
- package/src/mocks/files/data-payee-memo.ofx +75 -0
- package/src/mocks/files/data-payee-memo.qif +17 -0
- package/src/mocks/files/data.ofx +124 -0
- package/src/mocks/files/data.qfx +124 -0
- package/src/mocks/files/data.qif +91 -0
- package/src/mocks/files/default-budget-template/db.sqlite +0 -0
- package/src/mocks/files/default-budget-template/metadata.json +6 -0
- package/src/mocks/files/html-vals.qfx +17 -0
- package/src/mocks/index.ts +221 -0
- package/src/mocks/migrations/1508717984291_up_add-poop.sql +13 -0
- package/src/mocks/migrations/1508718036311_up_modify-poop.sql +2 -0
- package/src/mocks/migrations/1508727787513_remove-is_income.sql +15 -0
- package/src/mocks/random.ts +16 -0
- package/src/mocks/setup.ts +180 -0
- package/src/mocks/spreadsheet.ts +101 -0
- package/src/mocks/util.ts +82 -0
- package/src/platform/client/connection/README.md +3 -0
- package/src/platform/client/connection/__mocks__/index.ts +67 -0
- package/src/platform/client/connection/index-types.ts +95 -0
- package/src/platform/client/connection/index.browser.ts +213 -0
- package/src/platform/client/connection/index.ts +155 -0
- package/src/platform/client/undo/index.ts +59 -0
- package/src/platform/exceptions/__mocks__/index.ts +7 -0
- package/src/platform/exceptions/index.ts +9 -0
- package/src/platform/server/asyncStorage/__mocks__/index.ts +50 -0
- package/src/platform/server/asyncStorage/index-types.ts +35 -0
- package/src/platform/server/asyncStorage/index.api.ts +2 -0
- package/src/platform/server/asyncStorage/index.electron.ts +88 -0
- package/src/platform/server/asyncStorage/index.ts +126 -0
- package/src/platform/server/connection/README.md +3 -0
- package/src/platform/server/connection/__mocks__/index.ts +15 -0
- package/src/platform/server/connection/index-types.ts +20 -0
- package/src/platform/server/connection/index.api.ts +13 -0
- package/src/platform/server/connection/index.electron.ts +102 -0
- package/src/platform/server/connection/index.ts +154 -0
- package/src/platform/server/fetch/__mocks__/index.ts +3 -0
- package/src/platform/server/fetch/index.api.ts +1 -0
- package/src/platform/server/fetch/index.electron.ts +18 -0
- package/src/platform/server/fetch/index.ts +20 -0
- package/src/platform/server/fs/index.api.ts +198 -0
- package/src/platform/server/fs/index.electron.ts +208 -0
- package/src/platform/server/fs/index.test.ts +117 -0
- package/src/platform/server/fs/index.ts +416 -0
- package/src/platform/server/fs/path-join.api.ts +1 -0
- package/src/platform/server/fs/path-join.electron.ts +1 -0
- package/src/platform/server/fs/path-join.ts +97 -0
- package/src/platform/server/fs/shared.ts +33 -0
- package/src/platform/server/indexeddb/index.ts +115 -0
- package/src/platform/server/log/index.ts +43 -0
- package/src/platform/server/sqlite/index.api.ts +2 -0
- package/src/platform/server/sqlite/index.electron.ts +134 -0
- package/src/platform/server/sqlite/index.test.ts +108 -0
- package/src/platform/server/sqlite/index.ts +241 -0
- package/src/platform/server/sqlite/normalise.ts +9 -0
- package/src/platform/server/sqlite/unicodeLike.test.ts +58 -0
- package/src/platform/server/sqlite/unicodeLike.ts +31 -0
- package/src/server/__mocks__/post.ts +9 -0
- package/src/server/__snapshots__/main.test.ts.snap +199 -0
- package/src/server/__snapshots__/sheet.test.ts.snap +9 -0
- package/src/server/accounts/__snapshots__/sync.test.ts.snap +136 -0
- package/src/server/accounts/app-bank-sync.test.ts +136 -0
- package/src/server/accounts/app.ts +1294 -0
- package/src/server/accounts/link.ts +25 -0
- package/src/server/accounts/payees.ts +36 -0
- package/src/server/accounts/sync.test.ts +679 -0
- package/src/server/accounts/sync.ts +1168 -0
- package/src/server/accounts/title/index.ts +60 -0
- package/src/server/accounts/title/lower-case.ts +93 -0
- package/src/server/accounts/title/specials.ts +21 -0
- package/src/server/admin/app.ts +241 -0
- package/src/server/api-models.ts +244 -0
- package/src/server/api.test.ts +36 -0
- package/src/server/api.ts +1030 -0
- package/src/server/app.ts +91 -0
- package/src/server/aql/compiler.test.ts +966 -0
- package/src/server/aql/compiler.ts +1222 -0
- package/src/server/aql/exec.test.ts +289 -0
- package/src/server/aql/exec.ts +128 -0
- package/src/server/aql/index.ts +41 -0
- package/src/server/aql/schema/executors.test.ts +420 -0
- package/src/server/aql/schema/executors.ts +345 -0
- package/src/server/aql/schema/index.test.ts +67 -0
- package/src/server/aql/schema/index.ts +409 -0
- package/src/server/aql/schema-helpers.test.ts +242 -0
- package/src/server/aql/schema-helpers.ts +208 -0
- package/src/server/aql/views.test.ts +62 -0
- package/src/server/aql/views.ts +57 -0
- package/src/server/auth/app.ts +387 -0
- package/src/server/bench.ts +29 -0
- package/src/server/budget/actions.ts +686 -0
- package/src/server/budget/app.ts +469 -0
- package/src/server/budget/base.test.ts +340 -0
- package/src/server/budget/base.ts +339 -0
- package/src/server/budget/category-template-context.test.ts +1658 -0
- package/src/server/budget/category-template-context.ts +862 -0
- package/src/server/budget/cleanup-template.pegjs +27 -0
- package/src/server/budget/cleanup-template.ts +408 -0
- package/src/server/budget/envelope.ts +403 -0
- package/src/server/budget/goal-template.pegjs +110 -0
- package/src/server/budget/goal-template.ts +309 -0
- package/src/server/budget/report.ts +308 -0
- package/src/server/budget/schedule-template.test.ts +184 -0
- package/src/server/budget/schedule-template.ts +351 -0
- package/src/server/budget/statements.ts +60 -0
- package/src/server/budget/template-notes.test.ts +393 -0
- package/src/server/budget/template-notes.ts +323 -0
- package/src/server/budget/util.ts +25 -0
- package/src/server/budgetfiles/__snapshots__/backups.test.ts.snap +101 -0
- package/src/server/budgetfiles/app.ts +672 -0
- package/src/server/budgetfiles/backups.test.ts +79 -0
- package/src/server/budgetfiles/backups.ts +251 -0
- package/src/server/cloud-storage.ts +467 -0
- package/src/server/dashboard/app.ts +373 -0
- package/src/server/db/__snapshots__/index.test.ts.snap +271 -0
- package/src/server/db/index.test.ts +300 -0
- package/src/server/db/index.ts +855 -0
- package/src/server/db/mappings.ts +59 -0
- package/src/server/db/sort.ts +58 -0
- package/src/server/db/types/index.ts +342 -0
- package/src/server/db/util.ts +36 -0
- package/src/server/encryption/app.ts +133 -0
- package/src/server/encryption/encryption-internals.api.ts +2 -0
- package/src/server/encryption/encryption-internals.electron.ts +89 -0
- package/src/server/encryption/encryption-internals.ts +109 -0
- package/src/server/encryption/encryption.test.ts +19 -0
- package/src/server/encryption/index.test.ts +19 -0
- package/src/server/encryption/index.ts +89 -0
- package/src/server/errors.ts +110 -0
- package/src/server/filters/app.ts +191 -0
- package/src/server/importers/actual.ts +49 -0
- package/src/server/importers/index.ts +58 -0
- package/src/server/importers/ynab4-types.ts +163 -0
- package/src/server/importers/ynab4.ts +470 -0
- package/src/server/importers/ynab5-types.ts +290 -0
- package/src/server/importers/ynab5.ts +1193 -0
- package/src/server/main-app.ts +25 -0
- package/src/server/main.test.ts +392 -0
- package/src/server/main.ts +336 -0
- package/src/server/migrate/__snapshots__/migrations.test.ts.snap +17 -0
- package/src/server/migrate/cli.ts +100 -0
- package/src/server/migrate/migrations.test.ts +81 -0
- package/src/server/migrate/migrations.ts +192 -0
- package/src/server/models.ts +184 -0
- package/src/server/mutators.ts +139 -0
- package/src/server/notes/app.ts +18 -0
- package/src/server/payees/app.ts +351 -0
- package/src/server/polyfills.ts +26 -0
- package/src/server/post.ts +219 -0
- package/src/server/preferences/app.ts +249 -0
- package/src/server/prefs.ts +91 -0
- package/src/server/reports/app.ts +187 -0
- package/src/server/rules/action.ts +344 -0
- package/src/server/rules/app.ts +193 -0
- package/src/server/rules/condition.ts +436 -0
- package/src/server/rules/customFunctions.ts +61 -0
- package/src/server/rules/formula-action.test.ts +175 -0
- package/src/server/rules/handlebars-helpers.ts +131 -0
- package/src/server/rules/index.test.ts +1095 -0
- package/src/server/rules/index.ts +22 -0
- package/src/server/rules/rule-indexer.ts +89 -0
- package/src/server/rules/rule-utils.ts +274 -0
- package/src/server/rules/rule.ts +193 -0
- package/src/server/schedules/app.test.ts +502 -0
- package/src/server/schedules/app.ts +644 -0
- package/src/server/schedules/find-schedules.ts +391 -0
- package/src/server/server-config.ts +59 -0
- package/src/server/sheet.test.ts +101 -0
- package/src/server/sheet.ts +280 -0
- package/src/server/spreadsheet/__snapshots__/spreadsheet.test.ts.snap +5 -0
- package/src/server/spreadsheet/app.ts +54 -0
- package/src/server/spreadsheet/globals.ts +13 -0
- package/src/server/spreadsheet/graph-data-structure.ts +165 -0
- package/src/server/spreadsheet/scratch +60 -0
- package/src/server/spreadsheet/spreadsheet.test.ts +191 -0
- package/src/server/spreadsheet/spreadsheet.ts +523 -0
- package/src/server/spreadsheet/util.ts +15 -0
- package/src/server/sql/init.sql +88 -0
- package/src/server/sync/__snapshots__/sync.test.ts.snap +31 -0
- package/src/server/sync/app.ts +29 -0
- package/src/server/sync/encoder.ts +129 -0
- package/src/server/sync/index.ts +820 -0
- package/src/server/sync/make-test-message.ts +19 -0
- package/src/server/sync/migrate.test.ts +169 -0
- package/src/server/sync/migrate.ts +48 -0
- package/src/server/sync/repair.ts +39 -0
- package/src/server/sync/reset.ts +91 -0
- package/src/server/sync/sync.property.test.ts +385 -0
- package/src/server/sync/sync.test.ts +349 -0
- package/src/server/sync/utils.ts +3 -0
- package/src/server/tags/app.ts +101 -0
- package/src/server/tests/mockData.json +9352 -0
- package/src/server/tests/mockSyncServer.ts +119 -0
- package/src/server/tools/app.ts +152 -0
- package/src/server/transactions/__snapshots__/transaction-rules.test.ts.snap +173 -0
- package/src/server/transactions/__snapshots__/transfer.test.ts.snap +655 -0
- package/src/server/transactions/app.ts +136 -0
- package/src/server/transactions/export/export-to-csv.ts +132 -0
- package/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap +1582 -0
- package/src/server/transactions/import/ofx2json.test.ts +33 -0
- package/src/server/transactions/import/ofx2json.ts +157 -0
- package/src/server/transactions/import/parse-file.test.ts +224 -0
- package/src/server/transactions/import/parse-file.ts +286 -0
- package/src/server/transactions/import/qif2json.ts +110 -0
- package/src/server/transactions/import/xmlcamt2json.ts +168 -0
- package/src/server/transactions/index.ts +196 -0
- package/src/server/transactions/merge.test.ts +370 -0
- package/src/server/transactions/merge.ts +139 -0
- package/src/server/transactions/transaction-rules.test.ts +994 -0
- package/src/server/transactions/transaction-rules.ts +1038 -0
- package/src/server/transactions/transfer.test.ts +221 -0
- package/src/server/transactions/transfer.ts +173 -0
- package/src/server/undo.ts +271 -0
- package/src/server/update.ts +37 -0
- package/src/server/util/budget-name.ts +61 -0
- package/src/server/util/custom-sync-mapping.ts +48 -0
- package/src/server/util/rschedule.ts +9 -0
- package/src/shared/__mocks__/platform.ts +7 -0
- package/src/shared/__snapshots__/months.test.ts.snap +21 -0
- package/src/shared/arithmetic.test.ts +112 -0
- package/src/shared/arithmetic.ts +170 -0
- package/src/shared/async.test.ts +135 -0
- package/src/shared/async.ts +76 -0
- package/src/shared/constants.ts +5 -0
- package/src/shared/currencies.ts +70 -0
- package/src/shared/dashboard.ts +260 -0
- package/src/shared/environment.ts +18 -0
- package/src/shared/errors.ts +195 -0
- package/src/shared/locale.ts +27 -0
- package/src/shared/location-utils.test.ts +69 -0
- package/src/shared/location-utils.ts +49 -0
- package/src/shared/months.test.ts +5 -0
- package/src/shared/months.ts +485 -0
- package/src/shared/normalisation.ts +6 -0
- package/src/shared/platform.electron.ts +21 -0
- package/src/shared/platform.ts +20 -0
- package/src/shared/query.ts +176 -0
- package/src/shared/rules.test.ts +56 -0
- package/src/shared/rules.ts +371 -0
- package/src/shared/schedules.test.ts +570 -0
- package/src/shared/schedules.ts +560 -0
- package/src/shared/test-helpers.ts +156 -0
- package/src/shared/transactions.test.ts +275 -0
- package/src/shared/transactions.ts +433 -0
- package/src/shared/transfer.test.ts +75 -0
- package/src/shared/transfer.ts +16 -0
- package/src/shared/user.ts +4 -0
- package/src/shared/util.test.ts +240 -0
- package/src/shared/util.ts +633 -0
- package/src/types/api-handlers.ts +287 -0
- package/src/types/budget.ts +8 -0
- package/src/types/file.ts +47 -0
- package/src/types/handlers.ts +46 -0
- package/src/types/models/account.ts +24 -0
- package/src/types/models/bank-sync.ts +23 -0
- package/src/types/models/bank.ts +6 -0
- package/src/types/models/category-group.ts +11 -0
- package/src/types/models/category.ts +13 -0
- package/src/types/models/dashboard.ts +199 -0
- package/src/types/models/gocardless.ts +84 -0
- package/src/types/models/import-transaction.ts +56 -0
- package/src/types/models/index.ts +23 -0
- package/src/types/models/nearby-payee.ts +7 -0
- package/src/types/models/note.ts +4 -0
- package/src/types/models/openid.ts +8 -0
- package/src/types/models/payee-location.ts +8 -0
- package/src/types/models/payee.ts +10 -0
- package/src/types/models/pluggyai.ts +19 -0
- package/src/types/models/reports.ts +144 -0
- package/src/types/models/rule.ts +174 -0
- package/src/types/models/schedule.ts +49 -0
- package/src/types/models/simplefin.ts +28 -0
- package/src/types/models/tags.ts +6 -0
- package/src/types/models/templates.ts +135 -0
- package/src/types/models/transaction-filter.ts +9 -0
- package/src/types/models/transaction.ts +39 -0
- package/src/types/models/user-access.ts +10 -0
- package/src/types/models/user.ts +25 -0
- package/src/types/prefs.ts +167 -0
- package/src/types/server-events.ts +86 -0
- package/src/types/server-handlers.ts +27 -0
- package/src/types/util.ts +26 -0
- package/tsconfig.json +34 -0
- package/typings/pegjs.ts +1 -0
- package/typings/process-worker.ts +12 -0
- package/typings/vite-plugin-peggy-loader.ts +1 -0
- package/typings/window.ts +62 -0
- package/vite.config.ts +109 -0
- package/vite.desktop.config.ts +59 -0
- package/vitest.config.ts +43 -0
- package/vitest.web.config.ts +38 -0
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { getCurrency } from '#shared/currencies';
|
|
2
|
+
import type { Currency } from '#shared/currencies';
|
|
3
|
+
import { q } from '#shared/query';
|
|
4
|
+
import * as monthUtils from '../../shared/months';
|
|
5
|
+
import { amountToInteger, integerToAmount } from '../../shared/util';
|
|
6
|
+
import type { CategoryEntity } from '../../types/models';
|
|
7
|
+
import type {
|
|
8
|
+
AverageTemplate,
|
|
9
|
+
ByTemplate,
|
|
10
|
+
CopyTemplate,
|
|
11
|
+
GoalTemplate,
|
|
12
|
+
PercentageTemplate,
|
|
13
|
+
PeriodicTemplate,
|
|
14
|
+
RefillTemplate,
|
|
15
|
+
RemainderTemplate,
|
|
16
|
+
SimpleTemplate,
|
|
17
|
+
SpendTemplate,
|
|
18
|
+
Template,
|
|
19
|
+
} from '../../types/models/templates';
|
|
20
|
+
import { aqlQuery } from '../aql';
|
|
21
|
+
import * as db from '../db';
|
|
22
|
+
|
|
23
|
+
import { getSheetBoolean, getSheetValue, isReflectBudget } from './actions';
|
|
24
|
+
import { runSchedule } from './schedule-template';
|
|
25
|
+
import { getActiveSchedules } from './statements';
|
|
26
|
+
|
|
27
|
+
export class CategoryTemplateContext {
|
|
28
|
+
/*----------------------------------------------------------------------------
|
|
29
|
+
* Using This Class:
|
|
30
|
+
* 1. instantiate via `await categoryTemplate.init(templates, categoryID, month)`;
|
|
31
|
+
* templates: all templates for this category (including templates and goals)
|
|
32
|
+
* categoryID: the ID of the category that this Class will be for
|
|
33
|
+
* month: the month string of the month for templates being applied
|
|
34
|
+
* 2. gather needed data for external use. ex: remainder weights, priorities, limitExcess
|
|
35
|
+
* 3. run each priority level that is needed via runTemplatesForPriority
|
|
36
|
+
* 4. run the remainder templates via runRemainder()
|
|
37
|
+
* 5. finish processing by running getValues() and saving values for batch processing.
|
|
38
|
+
* Alternate:
|
|
39
|
+
* If the situation calls for it you can run all templates in a catagory in one go using the
|
|
40
|
+
* method runAll which will run all templates and goals for reference, and can optionally be saved
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
//-----------------------------------------------------------------------------
|
|
44
|
+
// Class interface
|
|
45
|
+
|
|
46
|
+
// set up the class and check all templates
|
|
47
|
+
static async init(
|
|
48
|
+
templates: Template[],
|
|
49
|
+
category: CategoryEntity,
|
|
50
|
+
month: string,
|
|
51
|
+
budgeted: number,
|
|
52
|
+
) {
|
|
53
|
+
// get all the needed setup values
|
|
54
|
+
const lastMonthSheet = monthUtils.sheetForMonth(
|
|
55
|
+
monthUtils.subMonths(month, 1),
|
|
56
|
+
);
|
|
57
|
+
let fromLastMonth = await getSheetValue(
|
|
58
|
+
lastMonthSheet,
|
|
59
|
+
`leftover-${category.id}`,
|
|
60
|
+
);
|
|
61
|
+
const carryover = await getSheetBoolean(
|
|
62
|
+
lastMonthSheet,
|
|
63
|
+
`carryover-${category.id}`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (
|
|
67
|
+
(fromLastMonth < 0 && !carryover) || // overspend no carryover
|
|
68
|
+
category.is_income || // tracking budget income categories
|
|
69
|
+
(isReflectBudget() && !carryover) // tracking budget regular categories
|
|
70
|
+
) {
|
|
71
|
+
fromLastMonth = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// run all checks
|
|
75
|
+
await CategoryTemplateContext.checkByAndScheduleAndSpend(templates, month);
|
|
76
|
+
await CategoryTemplateContext.checkPercentage(templates);
|
|
77
|
+
|
|
78
|
+
const hideDecimal = await aqlQuery(
|
|
79
|
+
q('preferences').filter({ id: 'hideFraction' }).select('*'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const currencyPref = await aqlQuery(
|
|
83
|
+
q('preferences').filter({ id: 'defaultCurrencyCode' }).select('*'),
|
|
84
|
+
);
|
|
85
|
+
const currencyCode =
|
|
86
|
+
currencyPref.data.length > 0 ? currencyPref.data[0].value : '';
|
|
87
|
+
|
|
88
|
+
// call the private constructor
|
|
89
|
+
return new CategoryTemplateContext(
|
|
90
|
+
templates,
|
|
91
|
+
category,
|
|
92
|
+
month,
|
|
93
|
+
fromLastMonth,
|
|
94
|
+
budgeted,
|
|
95
|
+
currencyCode,
|
|
96
|
+
hideDecimal.data.length > 0
|
|
97
|
+
? hideDecimal.data[0].value === 'true'
|
|
98
|
+
: false,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isGoalOnly(): boolean {
|
|
103
|
+
// if there is only a goal
|
|
104
|
+
return (
|
|
105
|
+
this.templates.length === 0 &&
|
|
106
|
+
this.remainder.length === 0 &&
|
|
107
|
+
this.goals.length > 0
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
getPriorities(): number[] {
|
|
111
|
+
return Array.from(this.priorities);
|
|
112
|
+
}
|
|
113
|
+
hasRemainder(): boolean {
|
|
114
|
+
return this.remainderWeight > 0 && !this.limitMet;
|
|
115
|
+
}
|
|
116
|
+
getRemainderWeight(): number {
|
|
117
|
+
return this.remainderWeight;
|
|
118
|
+
}
|
|
119
|
+
getLimitExcess(): number {
|
|
120
|
+
return this.limitExcess;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// what is the full requested amount this month
|
|
124
|
+
async runAll(available: number) {
|
|
125
|
+
let toBudget: number = 0;
|
|
126
|
+
const prioritiesSorted = new Int32Array(
|
|
127
|
+
[...this.getPriorities()].sort((a, b) => a - b),
|
|
128
|
+
);
|
|
129
|
+
for (let i = 0; i < prioritiesSorted.length; i++) {
|
|
130
|
+
const p = prioritiesSorted[i];
|
|
131
|
+
toBudget += await this.runTemplatesForPriority(p, available, available);
|
|
132
|
+
}
|
|
133
|
+
return toBudget;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// run all templates in a given priority level
|
|
137
|
+
// return: amount budgeted in this priority level
|
|
138
|
+
async runTemplatesForPriority(
|
|
139
|
+
priority: number,
|
|
140
|
+
budgetAvail: number,
|
|
141
|
+
availStart: number,
|
|
142
|
+
): Promise<number> {
|
|
143
|
+
if (!this.priorities.has(priority)) return 0;
|
|
144
|
+
if (this.limitMet) return 0;
|
|
145
|
+
|
|
146
|
+
const t = this.templates.filter(
|
|
147
|
+
t => t.directive === 'template' && t.priority === priority,
|
|
148
|
+
);
|
|
149
|
+
let available = budgetAvail || 0;
|
|
150
|
+
let toBudget = 0;
|
|
151
|
+
let byFlag = false;
|
|
152
|
+
let remainder = 0;
|
|
153
|
+
let scheduleFlag = false;
|
|
154
|
+
// switch on template type and calculate the amount for the line
|
|
155
|
+
for (const template of t) {
|
|
156
|
+
let newBudget = 0;
|
|
157
|
+
switch (template.type) {
|
|
158
|
+
case 'simple': {
|
|
159
|
+
newBudget = CategoryTemplateContext.runSimple(template, this);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case 'refill': {
|
|
163
|
+
newBudget = CategoryTemplateContext.runRefill(template, this);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'copy': {
|
|
167
|
+
newBudget = await CategoryTemplateContext.runCopy(template, this);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'periodic': {
|
|
171
|
+
newBudget = CategoryTemplateContext.runPeriodic(template, this);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'spend': {
|
|
175
|
+
newBudget = await CategoryTemplateContext.runSpend(template, this);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'percentage': {
|
|
179
|
+
newBudget = await CategoryTemplateContext.runPercentage(
|
|
180
|
+
template,
|
|
181
|
+
availStart,
|
|
182
|
+
this,
|
|
183
|
+
);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case 'by': {
|
|
187
|
+
// all by's get run at once
|
|
188
|
+
if (!byFlag) {
|
|
189
|
+
newBudget = CategoryTemplateContext.runBy(this);
|
|
190
|
+
} else {
|
|
191
|
+
newBudget = 0;
|
|
192
|
+
}
|
|
193
|
+
byFlag = true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
case 'schedule': {
|
|
197
|
+
if (!scheduleFlag) {
|
|
198
|
+
const budgeted = this.fromLastMonth + toBudget;
|
|
199
|
+
const ret = await runSchedule(
|
|
200
|
+
t,
|
|
201
|
+
this.month,
|
|
202
|
+
budgeted,
|
|
203
|
+
remainder,
|
|
204
|
+
this.fromLastMonth,
|
|
205
|
+
toBudget,
|
|
206
|
+
[],
|
|
207
|
+
this.category,
|
|
208
|
+
this.currency,
|
|
209
|
+
);
|
|
210
|
+
// Schedules assume that its to budget value is the whole thing so this
|
|
211
|
+
// needs to remove the previous funds so they aren't double counted
|
|
212
|
+
newBudget = ret.to_budget - toBudget;
|
|
213
|
+
remainder = ret.remainder;
|
|
214
|
+
scheduleFlag = true;
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'average': {
|
|
219
|
+
newBudget = await CategoryTemplateContext.runAverage(template, this);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
default: {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
available = available - newBudget;
|
|
228
|
+
toBudget += newBudget;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//check limit
|
|
232
|
+
if (this.limitCheck) {
|
|
233
|
+
if (
|
|
234
|
+
toBudget + this.toBudgetAmount + this.fromLastMonth >=
|
|
235
|
+
this.limitAmount
|
|
236
|
+
) {
|
|
237
|
+
const orig = toBudget;
|
|
238
|
+
toBudget = this.limitAmount - this.toBudgetAmount - this.fromLastMonth;
|
|
239
|
+
this.limitMet = true;
|
|
240
|
+
available = available + orig - toBudget;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
//round all budget values if needed
|
|
245
|
+
if (this.hideDecimal) toBudget = this.removeFraction(toBudget);
|
|
246
|
+
|
|
247
|
+
// don't overbudget when using a priority unless income category
|
|
248
|
+
if (priority > 0 && available < 0 && !this.category.is_income) {
|
|
249
|
+
this.fullAmount = (this.fullAmount || 0) + toBudget;
|
|
250
|
+
toBudget = Math.max(0, toBudget + available);
|
|
251
|
+
this.toBudgetAmount += toBudget;
|
|
252
|
+
} else {
|
|
253
|
+
this.fullAmount = (this.fullAmount || 0) + toBudget;
|
|
254
|
+
this.toBudgetAmount += toBudget;
|
|
255
|
+
}
|
|
256
|
+
return this.category.is_income ? -toBudget : toBudget;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
runRemainder(budgetAvail: number, perWeight: number) {
|
|
260
|
+
if (this.remainder.length === 0) return 0;
|
|
261
|
+
let toBudget = Math.round(this.remainderWeight * perWeight);
|
|
262
|
+
|
|
263
|
+
let smallest = 1;
|
|
264
|
+
if (this.hideDecimal) {
|
|
265
|
+
// handle hideDecimal
|
|
266
|
+
toBudget = this.removeFraction(toBudget);
|
|
267
|
+
smallest = 100;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
//check possible overbudget from rounding, 1cent leftover
|
|
271
|
+
if (toBudget > budgetAvail || budgetAvail - toBudget <= smallest) {
|
|
272
|
+
toBudget = budgetAvail;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (this.limitCheck) {
|
|
276
|
+
if (
|
|
277
|
+
toBudget + this.toBudgetAmount + this.fromLastMonth >=
|
|
278
|
+
this.limitAmount
|
|
279
|
+
) {
|
|
280
|
+
toBudget = this.limitAmount - this.toBudgetAmount - this.fromLastMonth;
|
|
281
|
+
this.limitMet = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.toBudgetAmount += toBudget;
|
|
286
|
+
return toBudget;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getValues() {
|
|
290
|
+
this.runGoal();
|
|
291
|
+
return {
|
|
292
|
+
budgeted: this.toBudgetAmount,
|
|
293
|
+
goal: this.goalAmount,
|
|
294
|
+
longGoal: this.isLongGoal,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
//-----------------------------------------------------------------------------
|
|
299
|
+
// Implementation
|
|
300
|
+
readonly category: CategoryEntity; //readonly so we can double check the category this is using
|
|
301
|
+
private month: string;
|
|
302
|
+
private templates: Template[] = [];
|
|
303
|
+
private remainder: RemainderTemplate[] = [];
|
|
304
|
+
private goals: GoalTemplate[] = [];
|
|
305
|
+
private priorities: Set<number> = new Set();
|
|
306
|
+
readonly hideDecimal: boolean = false;
|
|
307
|
+
private remainderWeight: number = 0;
|
|
308
|
+
private toBudgetAmount: number = 0; // amount that will be budgeted by the templates
|
|
309
|
+
private fullAmount: number | null = null; // the full requested amount, start null for remainder only cats
|
|
310
|
+
private isLongGoal: boolean | null = null; //defaulting the goals to null so templates can be unset
|
|
311
|
+
private goalAmount: number | null = null;
|
|
312
|
+
private fromLastMonth = 0; // leftover from last month
|
|
313
|
+
private limitMet = false;
|
|
314
|
+
private limitExcess: number = 0;
|
|
315
|
+
private limitAmount = 0;
|
|
316
|
+
private limitCheck = false;
|
|
317
|
+
private limitHold = false;
|
|
318
|
+
readonly previouslyBudgeted: number = 0;
|
|
319
|
+
private currency: Currency;
|
|
320
|
+
|
|
321
|
+
protected constructor(
|
|
322
|
+
templates: Template[],
|
|
323
|
+
category: CategoryEntity,
|
|
324
|
+
month: string,
|
|
325
|
+
fromLastMonth: number,
|
|
326
|
+
budgeted: number,
|
|
327
|
+
currencyCode: string,
|
|
328
|
+
hideDecimal: boolean = false,
|
|
329
|
+
) {
|
|
330
|
+
this.category = category;
|
|
331
|
+
this.month = month;
|
|
332
|
+
this.fromLastMonth = fromLastMonth;
|
|
333
|
+
this.previouslyBudgeted = budgeted;
|
|
334
|
+
this.currency = getCurrency(currencyCode);
|
|
335
|
+
this.hideDecimal = hideDecimal;
|
|
336
|
+
// sort the template lines into regular template, goals, and remainder templates
|
|
337
|
+
if (templates) {
|
|
338
|
+
templates.forEach(t => {
|
|
339
|
+
if (
|
|
340
|
+
t.directive === 'template' &&
|
|
341
|
+
t.type !== 'remainder' &&
|
|
342
|
+
t.type !== 'limit'
|
|
343
|
+
) {
|
|
344
|
+
this.templates.push(t);
|
|
345
|
+
if (t.priority !== null) this.priorities.add(t.priority);
|
|
346
|
+
} else if (t.directive === 'template' && t.type === 'remainder') {
|
|
347
|
+
this.remainder.push(t);
|
|
348
|
+
this.remainderWeight += t.weight;
|
|
349
|
+
} else if (t.directive === 'goal' && t.type === 'goal') {
|
|
350
|
+
this.goals.push(t);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.checkLimit(templates);
|
|
356
|
+
this.checkSpend();
|
|
357
|
+
this.checkGoal();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private runGoal() {
|
|
361
|
+
if (this.goals.length > 0) {
|
|
362
|
+
if (this.isGoalOnly()) this.toBudgetAmount = this.previouslyBudgeted;
|
|
363
|
+
this.isLongGoal = true;
|
|
364
|
+
this.goalAmount = amountToInteger(
|
|
365
|
+
this.goals[0].amount,
|
|
366
|
+
this.currency.decimalPlaces,
|
|
367
|
+
);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
this.goalAmount = this.fullAmount;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
//-----------------------------------------------------------------------------
|
|
374
|
+
// Template Validation
|
|
375
|
+
static async checkByAndScheduleAndSpend(
|
|
376
|
+
templates: Template[],
|
|
377
|
+
month: string,
|
|
378
|
+
) {
|
|
379
|
+
if (
|
|
380
|
+
templates.filter(t => t.type === 'schedule' || t.type === 'by').length ===
|
|
381
|
+
0
|
|
382
|
+
) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
//check schedule names
|
|
386
|
+
const scheduleNames = (await getActiveSchedules()).map(({ name }) =>
|
|
387
|
+
name.trim(),
|
|
388
|
+
);
|
|
389
|
+
templates
|
|
390
|
+
.filter(t => t.type === 'schedule')
|
|
391
|
+
.forEach(t => {
|
|
392
|
+
if (!scheduleNames.includes(t.name.trim())) {
|
|
393
|
+
throw new Error(`Schedule ${t.name.trim()} does not exist`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
//find lowest priority
|
|
397
|
+
const lowestPriority = Math.min(
|
|
398
|
+
...templates
|
|
399
|
+
.filter(t => t.type === 'schedule' || t.type === 'by')
|
|
400
|
+
.map(t => t.priority),
|
|
401
|
+
);
|
|
402
|
+
//warn if priority needs fixed
|
|
403
|
+
templates
|
|
404
|
+
.filter(t => t.type === 'schedule' || t.type === 'by')
|
|
405
|
+
.forEach(t => {
|
|
406
|
+
if (t.priority !== lowestPriority) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`Schedule and By templates must be the same priority level. Fix by setting all Schedule and By templates to priority level ${lowestPriority}`,
|
|
409
|
+
);
|
|
410
|
+
//t.priority = lowestPriority;
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
// check if the target date is past and not repeating
|
|
414
|
+
templates
|
|
415
|
+
.filter(t => t.type === 'by' || t.type === 'spend')
|
|
416
|
+
.forEach(t => {
|
|
417
|
+
const range = monthUtils.differenceInCalendarMonths(
|
|
418
|
+
`${t.month}`,
|
|
419
|
+
month,
|
|
420
|
+
);
|
|
421
|
+
if (range < 0 && !(t.repeat || t.annual)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Target month has passed, remove or update the target month`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
static async checkPercentage(templates: Template[]) {
|
|
430
|
+
const pt = templates.filter(t => t.type === 'percentage');
|
|
431
|
+
if (pt.length === 0) return;
|
|
432
|
+
const reqCategories = pt.map(t => t.category.toLowerCase());
|
|
433
|
+
|
|
434
|
+
const availCategories = await db.getCategories();
|
|
435
|
+
const availNames = availCategories
|
|
436
|
+
.filter(c => c.is_income)
|
|
437
|
+
.map(c => c.name.toLocaleLowerCase());
|
|
438
|
+
|
|
439
|
+
reqCategories.forEach(n => {
|
|
440
|
+
if (n === 'available funds' || n === 'all income') {
|
|
441
|
+
//skip the name check since these are special
|
|
442
|
+
} else if (!availNames.includes(n)) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Category \x22${n}\x22 is not found in available income categories`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private checkLimit(templates: Template[]) {
|
|
451
|
+
for (const template of templates.filter(
|
|
452
|
+
t =>
|
|
453
|
+
t.type === 'simple' ||
|
|
454
|
+
t.type === 'periodic' ||
|
|
455
|
+
t.type === 'limit' ||
|
|
456
|
+
t.type === 'remainder',
|
|
457
|
+
)) {
|
|
458
|
+
let limitDef;
|
|
459
|
+
if (template.type === 'limit') {
|
|
460
|
+
limitDef = template;
|
|
461
|
+
} else {
|
|
462
|
+
if (template.limit) {
|
|
463
|
+
limitDef = template.limit;
|
|
464
|
+
} else {
|
|
465
|
+
continue; // may not have a limit defined in the template
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (this.limitCheck) {
|
|
470
|
+
throw new Error('Only one `up to` allowed per category');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (limitDef.period === 'daily') {
|
|
474
|
+
const numDays = monthUtils.differenceInCalendarDays(
|
|
475
|
+
monthUtils.addMonths(this.month, 1),
|
|
476
|
+
this.month,
|
|
477
|
+
);
|
|
478
|
+
this.limitAmount +=
|
|
479
|
+
amountToInteger(limitDef.amount, this.currency.decimalPlaces) *
|
|
480
|
+
numDays;
|
|
481
|
+
} else if (limitDef.period === 'weekly') {
|
|
482
|
+
if (!limitDef.start) {
|
|
483
|
+
throw new Error('Weekly limit requires a start date (YYYY-MM-DD)');
|
|
484
|
+
}
|
|
485
|
+
const nextMonth = monthUtils.nextMonth(this.month);
|
|
486
|
+
let week = limitDef.start;
|
|
487
|
+
const baseLimit = amountToInteger(
|
|
488
|
+
limitDef.amount,
|
|
489
|
+
this.currency.decimalPlaces,
|
|
490
|
+
);
|
|
491
|
+
while (week < nextMonth) {
|
|
492
|
+
if (week >= this.month) {
|
|
493
|
+
this.limitAmount += baseLimit;
|
|
494
|
+
}
|
|
495
|
+
week = monthUtils.addWeeks(week, 1);
|
|
496
|
+
}
|
|
497
|
+
} else if (limitDef.period === 'monthly') {
|
|
498
|
+
this.limitAmount = amountToInteger(
|
|
499
|
+
limitDef.amount,
|
|
500
|
+
this.currency.decimalPlaces,
|
|
501
|
+
);
|
|
502
|
+
} else {
|
|
503
|
+
throw new Error('Invalid limit period. Check template syntax');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
//amount is good save the rest
|
|
507
|
+
this.limitCheck = true;
|
|
508
|
+
this.limitHold = limitDef.hold ? true : false;
|
|
509
|
+
// check if the limit is already met and save the excess
|
|
510
|
+
if (this.fromLastMonth >= this.limitAmount) {
|
|
511
|
+
this.limitMet = true;
|
|
512
|
+
if (this.limitHold) {
|
|
513
|
+
this.limitExcess = 0;
|
|
514
|
+
this.toBudgetAmount = 0;
|
|
515
|
+
this.fullAmount = 0;
|
|
516
|
+
} else {
|
|
517
|
+
this.limitExcess = this.fromLastMonth - this.limitAmount;
|
|
518
|
+
this.toBudgetAmount = -this.limitExcess;
|
|
519
|
+
this.fullAmount = -this.limitExcess;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private checkSpend() {
|
|
526
|
+
const st = this.templates.filter(t => t.type === 'spend');
|
|
527
|
+
if (st.length > 1) {
|
|
528
|
+
throw new Error('Only one spend template is allowed per category');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private checkGoal() {
|
|
533
|
+
if (this.goals.length > 1) {
|
|
534
|
+
throw new Error(`Only one #goal is allowed per category`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private removeFraction(amount: number): number {
|
|
539
|
+
return amountToInteger(
|
|
540
|
+
Math.round(integerToAmount(amount, this.currency.decimalPlaces)),
|
|
541
|
+
this.currency.decimalPlaces,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
//-----------------------------------------------------------------------------
|
|
546
|
+
// Processor Functions
|
|
547
|
+
|
|
548
|
+
static runSimple(
|
|
549
|
+
template: SimpleTemplate,
|
|
550
|
+
templateContext: CategoryTemplateContext,
|
|
551
|
+
): number {
|
|
552
|
+
if (template.monthly != null) {
|
|
553
|
+
return amountToInteger(
|
|
554
|
+
template.monthly,
|
|
555
|
+
templateContext.currency.decimalPlaces,
|
|
556
|
+
);
|
|
557
|
+
} else {
|
|
558
|
+
return templateContext.limitAmount - templateContext.fromLastMonth;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
static runRefill(
|
|
563
|
+
template: RefillTemplate,
|
|
564
|
+
templateContext: CategoryTemplateContext,
|
|
565
|
+
): number {
|
|
566
|
+
return templateContext.limitAmount - templateContext.fromLastMonth;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
static async runCopy(
|
|
570
|
+
template: CopyTemplate,
|
|
571
|
+
templateContext: CategoryTemplateContext,
|
|
572
|
+
): Promise<number> {
|
|
573
|
+
const sheetName = monthUtils.sheetForMonth(
|
|
574
|
+
monthUtils.subMonths(templateContext.month, template.lookBack),
|
|
575
|
+
);
|
|
576
|
+
return await getSheetValue(
|
|
577
|
+
sheetName,
|
|
578
|
+
`budget-${templateContext.category.id}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
static runPeriodic(
|
|
583
|
+
template: PeriodicTemplate,
|
|
584
|
+
templateContext: CategoryTemplateContext,
|
|
585
|
+
): number {
|
|
586
|
+
let toBudget = 0;
|
|
587
|
+
const amount = amountToInteger(
|
|
588
|
+
template.amount,
|
|
589
|
+
templateContext.currency.decimalPlaces,
|
|
590
|
+
);
|
|
591
|
+
const period = template.period.period;
|
|
592
|
+
const numPeriods = template.period.amount;
|
|
593
|
+
let date =
|
|
594
|
+
template.starting ?? monthUtils.firstDayOfMonth(templateContext.month);
|
|
595
|
+
|
|
596
|
+
let dateShiftFunction;
|
|
597
|
+
switch (period) {
|
|
598
|
+
case 'day':
|
|
599
|
+
dateShiftFunction = monthUtils.addDays;
|
|
600
|
+
break;
|
|
601
|
+
case 'week':
|
|
602
|
+
dateShiftFunction = monthUtils.addWeeks;
|
|
603
|
+
break;
|
|
604
|
+
case 'month':
|
|
605
|
+
dateShiftFunction = monthUtils.addMonths;
|
|
606
|
+
break;
|
|
607
|
+
case 'year':
|
|
608
|
+
// the addYears function doesn't return the month number, so use addMonths
|
|
609
|
+
dateShiftFunction = (date: string | Date, numPeriods: number) =>
|
|
610
|
+
monthUtils.addMonths(date, numPeriods * 12);
|
|
611
|
+
break;
|
|
612
|
+
default:
|
|
613
|
+
throw new Error(`Unrecognized periodic period: ${String(period)}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
//shift the starting date until its in our month or in the future
|
|
617
|
+
while (templateContext.month > date) {
|
|
618
|
+
date = dateShiftFunction(date, numPeriods);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (
|
|
622
|
+
monthUtils.differenceInCalendarMonths(templateContext.month, date) < 0
|
|
623
|
+
) {
|
|
624
|
+
return 0;
|
|
625
|
+
} // nothing needed this month
|
|
626
|
+
|
|
627
|
+
const nextMonth = monthUtils.addMonths(templateContext.month, 1);
|
|
628
|
+
while (date < nextMonth) {
|
|
629
|
+
toBudget += amount;
|
|
630
|
+
date = dateShiftFunction(date, numPeriods);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return toBudget;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
static async runSpend(
|
|
637
|
+
template: SpendTemplate,
|
|
638
|
+
templateContext: CategoryTemplateContext,
|
|
639
|
+
): Promise<number> {
|
|
640
|
+
let fromMonth = `${template.from}`;
|
|
641
|
+
let toMonth = `${template.month}`;
|
|
642
|
+
let alreadyBudgeted = templateContext.fromLastMonth;
|
|
643
|
+
let firstMonth = true;
|
|
644
|
+
|
|
645
|
+
//update months if needed
|
|
646
|
+
const repeat = template.annual
|
|
647
|
+
? (template.repeat || 1) * 12
|
|
648
|
+
: template.repeat;
|
|
649
|
+
let m = monthUtils.differenceInCalendarMonths(
|
|
650
|
+
toMonth,
|
|
651
|
+
templateContext.month,
|
|
652
|
+
);
|
|
653
|
+
if (repeat && m < 0) {
|
|
654
|
+
while (m < 0) {
|
|
655
|
+
toMonth = monthUtils.addMonths(toMonth, repeat);
|
|
656
|
+
fromMonth = monthUtils.addMonths(fromMonth, repeat);
|
|
657
|
+
m = monthUtils.differenceInCalendarMonths(
|
|
658
|
+
toMonth,
|
|
659
|
+
templateContext.month,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
for (
|
|
665
|
+
let m = fromMonth;
|
|
666
|
+
monthUtils.differenceInCalendarMonths(templateContext.month, m) > 0;
|
|
667
|
+
m = monthUtils.addMonths(m, 1)
|
|
668
|
+
) {
|
|
669
|
+
const sheetName = monthUtils.sheetForMonth(m);
|
|
670
|
+
if (firstMonth) {
|
|
671
|
+
//TODO figure out if I already found these values and can pass them in
|
|
672
|
+
const spent = await getSheetValue(
|
|
673
|
+
sheetName,
|
|
674
|
+
`sum-amount-${templateContext.category.id}`,
|
|
675
|
+
);
|
|
676
|
+
const balance = await getSheetValue(
|
|
677
|
+
sheetName,
|
|
678
|
+
`leftover-${templateContext.category.id}`,
|
|
679
|
+
);
|
|
680
|
+
alreadyBudgeted = balance - spent;
|
|
681
|
+
firstMonth = false;
|
|
682
|
+
} else {
|
|
683
|
+
alreadyBudgeted += await getSheetValue(
|
|
684
|
+
sheetName,
|
|
685
|
+
`budget-${templateContext.category.id}`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const numMonths = monthUtils.differenceInCalendarMonths(
|
|
691
|
+
toMonth,
|
|
692
|
+
templateContext.month,
|
|
693
|
+
);
|
|
694
|
+
const target = amountToInteger(
|
|
695
|
+
template.amount,
|
|
696
|
+
templateContext.currency.decimalPlaces,
|
|
697
|
+
);
|
|
698
|
+
if (numMonths < 0) {
|
|
699
|
+
return 0;
|
|
700
|
+
} else {
|
|
701
|
+
return Math.round((target - alreadyBudgeted) / (numMonths + 1));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
static async runPercentage(
|
|
706
|
+
template: PercentageTemplate,
|
|
707
|
+
availableFunds: number,
|
|
708
|
+
templateContext: CategoryTemplateContext,
|
|
709
|
+
): Promise<number> {
|
|
710
|
+
const percent = template.percent;
|
|
711
|
+
const cat = template.category.toLowerCase();
|
|
712
|
+
const prev = template.previous;
|
|
713
|
+
let sheetName;
|
|
714
|
+
let monthlyIncome = 1;
|
|
715
|
+
|
|
716
|
+
//choose the sheet to find income for
|
|
717
|
+
if (prev) {
|
|
718
|
+
sheetName = monthUtils.sheetForMonth(
|
|
719
|
+
monthUtils.subMonths(templateContext.month, 1),
|
|
720
|
+
);
|
|
721
|
+
} else {
|
|
722
|
+
sheetName = monthUtils.sheetForMonth(templateContext.month);
|
|
723
|
+
}
|
|
724
|
+
if (cat === 'all income') {
|
|
725
|
+
monthlyIncome = await getSheetValue(sheetName, `total-income`);
|
|
726
|
+
} else if (cat === 'available funds') {
|
|
727
|
+
monthlyIncome = availableFunds;
|
|
728
|
+
} else {
|
|
729
|
+
const incomeCat = (await db.getCategories()).find(
|
|
730
|
+
c => c.is_income && c.name.toLowerCase() === cat,
|
|
731
|
+
);
|
|
732
|
+
if (!incomeCat) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`Income category "${template.category}" not found for percentage template`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
monthlyIncome = await getSheetValue(
|
|
738
|
+
sheetName,
|
|
739
|
+
`sum-amount-${incomeCat.id}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return Math.max(0, Math.round(monthlyIncome * (percent / 100)));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
static async runAverage(
|
|
747
|
+
template: AverageTemplate,
|
|
748
|
+
templateContext: CategoryTemplateContext,
|
|
749
|
+
): Promise<number> {
|
|
750
|
+
let sum = 0;
|
|
751
|
+
for (let i = 1; i <= template.numMonths; i++) {
|
|
752
|
+
const sheetName = monthUtils.sheetForMonth(
|
|
753
|
+
monthUtils.subMonths(templateContext.month, i),
|
|
754
|
+
);
|
|
755
|
+
sum += await getSheetValue(
|
|
756
|
+
sheetName,
|
|
757
|
+
`sum-amount-${templateContext.category.id}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// negate as sheet value is cost ie negative
|
|
762
|
+
let average = -(sum / template.numMonths);
|
|
763
|
+
|
|
764
|
+
if (template.adjustment !== undefined && template.adjustmentType) {
|
|
765
|
+
switch (template.adjustmentType) {
|
|
766
|
+
case 'percent': {
|
|
767
|
+
const adjustmentFactor = 1 + template.adjustment / 100;
|
|
768
|
+
average = adjustmentFactor * average;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'fixed': {
|
|
772
|
+
average += amountToInteger(
|
|
773
|
+
template.adjustment,
|
|
774
|
+
templateContext.currency.decimalPlaces,
|
|
775
|
+
);
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
default:
|
|
780
|
+
//no valid adjustment was found
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return Math.round(average);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
static runBy(templateContext: CategoryTemplateContext): number {
|
|
788
|
+
const byTemplates: ByTemplate[] = templateContext.templates.filter(
|
|
789
|
+
t => t.type === 'by',
|
|
790
|
+
);
|
|
791
|
+
const savedInfo = [];
|
|
792
|
+
let totalNeeded = 0;
|
|
793
|
+
let workingShortNumMonths;
|
|
794
|
+
//find shortest time period
|
|
795
|
+
for (let i = 0; i < byTemplates.length; i++) {
|
|
796
|
+
const template = byTemplates[i];
|
|
797
|
+
let targetMonth = `${template.month}`;
|
|
798
|
+
const period = template.annual
|
|
799
|
+
? (template.repeat || 1) * 12
|
|
800
|
+
: template.repeat != null
|
|
801
|
+
? template.repeat
|
|
802
|
+
: null;
|
|
803
|
+
let numMonths = monthUtils.differenceInCalendarMonths(
|
|
804
|
+
targetMonth,
|
|
805
|
+
templateContext.month,
|
|
806
|
+
);
|
|
807
|
+
while (numMonths < 0 && period) {
|
|
808
|
+
targetMonth = monthUtils.addMonths(targetMonth, period);
|
|
809
|
+
numMonths = monthUtils.differenceInCalendarMonths(
|
|
810
|
+
targetMonth,
|
|
811
|
+
templateContext.month,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
savedInfo.push({ numMonths, period });
|
|
815
|
+
if (
|
|
816
|
+
workingShortNumMonths === undefined ||
|
|
817
|
+
numMonths < workingShortNumMonths
|
|
818
|
+
) {
|
|
819
|
+
workingShortNumMonths = numMonths;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// calculate needed funds per template
|
|
824
|
+
const shortNumMonths = workingShortNumMonths || 0;
|
|
825
|
+
for (let i = 0; i < byTemplates.length; i++) {
|
|
826
|
+
const template = byTemplates[i];
|
|
827
|
+
const numMonths = savedInfo[i].numMonths;
|
|
828
|
+
const period = savedInfo[i].period;
|
|
829
|
+
let amount;
|
|
830
|
+
// back interpolate what is needed in the short window
|
|
831
|
+
if (numMonths > shortNumMonths && period) {
|
|
832
|
+
amount = Math.round(
|
|
833
|
+
(amountToInteger(
|
|
834
|
+
template.amount,
|
|
835
|
+
templateContext.currency.decimalPlaces,
|
|
836
|
+
) /
|
|
837
|
+
period) *
|
|
838
|
+
(period - numMonths + shortNumMonths),
|
|
839
|
+
);
|
|
840
|
+
// fallback to this. This matches what the prior math accomplished, just more round about
|
|
841
|
+
} else if (numMonths > shortNumMonths) {
|
|
842
|
+
amount = Math.round(
|
|
843
|
+
(amountToInteger(
|
|
844
|
+
template.amount,
|
|
845
|
+
templateContext.currency.decimalPlaces,
|
|
846
|
+
) /
|
|
847
|
+
(numMonths + 1)) *
|
|
848
|
+
(shortNumMonths + 1),
|
|
849
|
+
);
|
|
850
|
+
} else {
|
|
851
|
+
amount = amountToInteger(
|
|
852
|
+
template.amount,
|
|
853
|
+
templateContext.currency.decimalPlaces,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
totalNeeded += amount;
|
|
857
|
+
}
|
|
858
|
+
return Math.round(
|
|
859
|
+
(totalNeeded - templateContext.fromLastMonth) / (shortNumMonths + 1),
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|