@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,393 @@
|
|
|
1
|
+
import type { Template } from '../../types/models/templates';
|
|
2
|
+
import * as db from '../db';
|
|
3
|
+
|
|
4
|
+
import { parse } from './goal-template.pegjs';
|
|
5
|
+
import {
|
|
6
|
+
getActiveSchedules,
|
|
7
|
+
getCategoriesWithTemplateNotes,
|
|
8
|
+
resetCategoryGoalDefsWithNoTemplates,
|
|
9
|
+
} from './statements';
|
|
10
|
+
import type { CategoryWithTemplateNote } from './statements';
|
|
11
|
+
import {
|
|
12
|
+
checkTemplateNotes,
|
|
13
|
+
storeNoteTemplates,
|
|
14
|
+
unparse,
|
|
15
|
+
} from './template-notes';
|
|
16
|
+
|
|
17
|
+
vi.mock('../db');
|
|
18
|
+
vi.mock('./statements');
|
|
19
|
+
|
|
20
|
+
function mockGetTemplateNotesForCategories(
|
|
21
|
+
templateNotes: CategoryWithTemplateNote[],
|
|
22
|
+
) {
|
|
23
|
+
vi.mocked(getCategoriesWithTemplateNotes).mockResolvedValue(templateNotes);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mockGetActiveSchedules(schedules: db.DbSchedule[]) {
|
|
27
|
+
vi.mocked(getActiveSchedules).mockResolvedValue(schedules);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mockDbUpdate() {
|
|
31
|
+
vi.mocked(db.updateWithSchema).mockResolvedValue(undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('storeNoteTemplates', () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const testCases = [
|
|
40
|
+
{
|
|
41
|
+
description: 'Stores templates for categories with valid template notes',
|
|
42
|
+
mockTemplateNotes: [
|
|
43
|
+
{
|
|
44
|
+
id: 'cat1',
|
|
45
|
+
name: 'Category 1',
|
|
46
|
+
note: '#template 10',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
expectedTemplates: [
|
|
50
|
+
{
|
|
51
|
+
type: 'simple',
|
|
52
|
+
monthly: 10,
|
|
53
|
+
limit: null,
|
|
54
|
+
priority: 0,
|
|
55
|
+
directive: 'template',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
description:
|
|
61
|
+
'Stores negative templates for categories with valid template notes',
|
|
62
|
+
mockTemplateNotes: [
|
|
63
|
+
{
|
|
64
|
+
id: 'cat1',
|
|
65
|
+
name: 'Category 1',
|
|
66
|
+
note: '#template -103.23',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
expectedTemplates: [
|
|
70
|
+
{
|
|
71
|
+
type: 'simple',
|
|
72
|
+
monthly: -103.23,
|
|
73
|
+
limit: null,
|
|
74
|
+
priority: 0,
|
|
75
|
+
directive: 'template',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
description:
|
|
81
|
+
'Stores template when prefix is used with valid template notes',
|
|
82
|
+
mockTemplateNotes: [
|
|
83
|
+
{
|
|
84
|
+
id: 'cat1',
|
|
85
|
+
name: 'Category 1',
|
|
86
|
+
note: 'test: #template 12',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
expectedTemplates: [
|
|
90
|
+
{
|
|
91
|
+
type: 'simple',
|
|
92
|
+
monthly: 12,
|
|
93
|
+
limit: null,
|
|
94
|
+
priority: 0,
|
|
95
|
+
directive: 'template',
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
description:
|
|
101
|
+
'Stores templates for categories with valid goal directive template notes',
|
|
102
|
+
mockTemplateNotes: [
|
|
103
|
+
{
|
|
104
|
+
id: 'cat1',
|
|
105
|
+
name: 'Category 1',
|
|
106
|
+
note: '#goal 10',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
expectedTemplates: [
|
|
110
|
+
{
|
|
111
|
+
type: 'goal',
|
|
112
|
+
amount: 10,
|
|
113
|
+
priority: null,
|
|
114
|
+
directive: 'goal',
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
description: 'Does not store empty template notes',
|
|
120
|
+
mockTemplateNotes: [{ id: 'cat1', name: 'Category 1', note: '' }],
|
|
121
|
+
expectedTemplates: [],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
description: 'Does not store non template notes',
|
|
125
|
+
mockTemplateNotes: [
|
|
126
|
+
{ id: 'cat1', name: 'Category 1', note: 'Not a template note' },
|
|
127
|
+
],
|
|
128
|
+
expectedTemplates: [],
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
it.each(testCases)(
|
|
133
|
+
'$description',
|
|
134
|
+
async ({ mockTemplateNotes, expectedTemplates }) => {
|
|
135
|
+
// Given
|
|
136
|
+
mockGetTemplateNotesForCategories(mockTemplateNotes);
|
|
137
|
+
mockDbUpdate();
|
|
138
|
+
|
|
139
|
+
// When
|
|
140
|
+
await storeNoteTemplates();
|
|
141
|
+
|
|
142
|
+
// Then
|
|
143
|
+
if (expectedTemplates.length === 0) {
|
|
144
|
+
expect(db.updateWithSchema).not.toHaveBeenCalled();
|
|
145
|
+
expect(resetCategoryGoalDefsWithNoTemplates).toHaveBeenCalled();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
mockTemplateNotes.forEach(({ id }) => {
|
|
150
|
+
expect(db.updateWithSchema).toHaveBeenCalledWith('categories', {
|
|
151
|
+
id,
|
|
152
|
+
goal_def: JSON.stringify(expectedTemplates),
|
|
153
|
+
template_settings: { source: 'notes' },
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
expect(resetCategoryGoalDefsWithNoTemplates).toHaveBeenCalled();
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('checkTemplates', () => {
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
vi.clearAllMocks();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const testCases = [
|
|
167
|
+
{
|
|
168
|
+
description: 'Returns success message when templates pass',
|
|
169
|
+
mockTemplateNotes: [
|
|
170
|
+
{
|
|
171
|
+
id: 'cat1',
|
|
172
|
+
name: 'Category 1',
|
|
173
|
+
note: '#template 10',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'cat1',
|
|
177
|
+
name: 'Category 1',
|
|
178
|
+
note: '#template schedule Mock Schedule 1',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
mockSchedules: mockSchedules(),
|
|
182
|
+
expected: {
|
|
183
|
+
type: 'message',
|
|
184
|
+
message: 'All templates passed! 🎉',
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
description: 'Skips notes that are not templates',
|
|
189
|
+
mockTemplateNotes: [
|
|
190
|
+
{
|
|
191
|
+
id: 'cat1',
|
|
192
|
+
name: 'Category 1',
|
|
193
|
+
note: 'Not a template note',
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
mockSchedules: mockSchedules(),
|
|
197
|
+
expected: {
|
|
198
|
+
type: 'message',
|
|
199
|
+
message: 'All templates passed! 🎉',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
description: 'Returns errors for templates with parsing errors',
|
|
204
|
+
mockTemplateNotes: [
|
|
205
|
+
{
|
|
206
|
+
id: 'cat1',
|
|
207
|
+
name: 'Category 1',
|
|
208
|
+
note: '#template broken template',
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
mockSchedules: mockSchedules(),
|
|
212
|
+
expected: {
|
|
213
|
+
sticky: true,
|
|
214
|
+
message: 'There were errors interpreting some templates:',
|
|
215
|
+
pre: 'Category 1: #template broken template',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
description: 'Returns errors for non-existent schedules',
|
|
220
|
+
mockTemplateNotes: [
|
|
221
|
+
{
|
|
222
|
+
id: 'cat1',
|
|
223
|
+
name: 'Category 1',
|
|
224
|
+
note: '#template schedule Non-existent Schedule',
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
mockSchedules: mockSchedules(),
|
|
228
|
+
expected: {
|
|
229
|
+
sticky: true,
|
|
230
|
+
message: 'There were errors interpreting some templates:',
|
|
231
|
+
pre: 'Category 1: Schedule "Non-existent Schedule" does not exist',
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
description: 'Returns errors for invalid increase schedule adjustments',
|
|
236
|
+
mockTemplateNotes: [
|
|
237
|
+
{
|
|
238
|
+
id: 'cat1',
|
|
239
|
+
name: 'Category 1',
|
|
240
|
+
note: '#template schedule Mock Schedule 1 [increase 1001%]',
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
mockSchedules: mockSchedules(),
|
|
244
|
+
expected: {
|
|
245
|
+
sticky: true,
|
|
246
|
+
message: 'There were errors interpreting some templates:',
|
|
247
|
+
pre: 'Category 1: #template schedule Mock Schedule 1 [increase 1001%]\nError: Invalid adjustment percentage (1001%). Must be between -100% and 1000%',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
description: 'Returns errors for invalid decrease schedule adjustments',
|
|
252
|
+
mockTemplateNotes: [
|
|
253
|
+
{
|
|
254
|
+
id: 'cat1',
|
|
255
|
+
name: 'Category 1',
|
|
256
|
+
note: '#template schedule Mock Schedule 1 [decrease 101%]',
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
mockSchedules: mockSchedules(),
|
|
260
|
+
expected: {
|
|
261
|
+
sticky: true,
|
|
262
|
+
message: 'There were errors interpreting some templates:',
|
|
263
|
+
pre: 'Category 1: #template schedule Mock Schedule 1 [decrease 101%]\nError: Invalid adjustment percentage (-101%). Must be between -100% and 1000%',
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
it.each(testCases)(
|
|
269
|
+
'$description',
|
|
270
|
+
async ({ mockTemplateNotes, mockSchedules, expected }) => {
|
|
271
|
+
// Given
|
|
272
|
+
mockGetTemplateNotesForCategories(mockTemplateNotes);
|
|
273
|
+
mockGetActiveSchedules(mockSchedules);
|
|
274
|
+
|
|
275
|
+
// When
|
|
276
|
+
const result = await checkTemplateNotes();
|
|
277
|
+
|
|
278
|
+
// Then
|
|
279
|
+
expect(result).toEqual(expected);
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
function mockSchedules(): db.DbSchedule[] {
|
|
285
|
+
return [
|
|
286
|
+
{
|
|
287
|
+
id: 'mock-schedule-1',
|
|
288
|
+
rule: 'mock-rule',
|
|
289
|
+
active: 1,
|
|
290
|
+
completed: 0,
|
|
291
|
+
posts_transaction: 0,
|
|
292
|
+
tombstone: 0,
|
|
293
|
+
name: 'Mock Schedule 1',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: 'mock-schedule-2',
|
|
297
|
+
rule: 'mock-rule',
|
|
298
|
+
active: 1,
|
|
299
|
+
completed: 0,
|
|
300
|
+
posts_transaction: 0,
|
|
301
|
+
tombstone: 0,
|
|
302
|
+
name: 'Mock Schedule 2',
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
describe('unparse/parse round-trip', () => {
|
|
308
|
+
const cases: string[] = [
|
|
309
|
+
// simple
|
|
310
|
+
'#template 10',
|
|
311
|
+
'#template up to 50',
|
|
312
|
+
'#template up to 25 per day hold',
|
|
313
|
+
'#template up to 100 per week starting 2025-01-01',
|
|
314
|
+
'#template-2 123.45',
|
|
315
|
+
// schedule
|
|
316
|
+
'#template schedule Rent',
|
|
317
|
+
'#template schedule full Mortgage',
|
|
318
|
+
'#template schedule Netflix [increase 10%]',
|
|
319
|
+
'#template schedule full Groceries [decrease 5%]',
|
|
320
|
+
// percentage
|
|
321
|
+
'#template 50% of Utilities',
|
|
322
|
+
'#template 75% of previous Dining Out',
|
|
323
|
+
// periodic
|
|
324
|
+
'#template 200 repeat every 2 months starting 2025-06-01',
|
|
325
|
+
'#template 300 repeat every week starting 2025-01-07',
|
|
326
|
+
'#template 400 repeat every year starting 2025-01-01 up to 50',
|
|
327
|
+
// by / spend
|
|
328
|
+
'#template 500 by 2025-12',
|
|
329
|
+
'#template 600 by 2025-11 repeat every month',
|
|
330
|
+
'#template 700 by 2025-10 repeat every 2 months',
|
|
331
|
+
'#template 800 by 2025-09 repeat every year',
|
|
332
|
+
'#template 900 by 2025-08 repeat every 3 years',
|
|
333
|
+
'#template 1000 by 2025-07 spend from 2025-01 repeat every month',
|
|
334
|
+
'#template 1100 by 2025-06 spend from 2025-02 repeat every 2 months',
|
|
335
|
+
// remainder
|
|
336
|
+
'#template remainder',
|
|
337
|
+
'#template remainder 2',
|
|
338
|
+
'#template remainder 3 up to 10',
|
|
339
|
+
// average
|
|
340
|
+
'#template average 6 months',
|
|
341
|
+
'#template-5 average 12 months',
|
|
342
|
+
// copy
|
|
343
|
+
'#template copy from 3 months ago',
|
|
344
|
+
'#template copy from 6 months ago',
|
|
345
|
+
// goal
|
|
346
|
+
'#goal 1234',
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
it.each(cases)('round-trips: %s', async original => {
|
|
350
|
+
const parsed: Template = parse(original);
|
|
351
|
+
const serialized = await unparse([parsed]);
|
|
352
|
+
const reparsed: Template = parse(serialized);
|
|
353
|
+
|
|
354
|
+
expect(parsed).toEqual(reparsed);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('unparse limit templates', () => {
|
|
359
|
+
it('serializes refill limits to notes syntax', async () => {
|
|
360
|
+
const serialized = await unparse([
|
|
361
|
+
{
|
|
362
|
+
type: 'limit',
|
|
363
|
+
amount: 150,
|
|
364
|
+
hold: false,
|
|
365
|
+
period: 'monthly',
|
|
366
|
+
directive: 'template',
|
|
367
|
+
priority: null,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
type: 'refill',
|
|
371
|
+
directive: 'template',
|
|
372
|
+
priority: 2,
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
expect(serialized).toBe('#template-2 up to 150');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('serializes non-refill limits with a zero base amount', async () => {
|
|
380
|
+
const serialized = await unparse([
|
|
381
|
+
{
|
|
382
|
+
type: 'limit',
|
|
383
|
+
amount: 200,
|
|
384
|
+
hold: false,
|
|
385
|
+
period: 'monthly',
|
|
386
|
+
directive: 'template',
|
|
387
|
+
priority: null,
|
|
388
|
+
},
|
|
389
|
+
]);
|
|
390
|
+
|
|
391
|
+
expect(serialized).toBe('#template 0 up to 200');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Template } from '../../types/models/templates';
|
|
2
|
+
|
|
3
|
+
import { storeTemplates } from './goal-template';
|
|
4
|
+
import { parse } from './goal-template.pegjs';
|
|
5
|
+
import {
|
|
6
|
+
getActiveSchedules,
|
|
7
|
+
getCategoriesWithTemplateNotes,
|
|
8
|
+
resetCategoryGoalDefsWithNoTemplates,
|
|
9
|
+
} from './statements';
|
|
10
|
+
import type { CategoryWithTemplateNote } from './statements';
|
|
11
|
+
|
|
12
|
+
type Notification = {
|
|
13
|
+
type?: 'message' | 'error' | 'warning' | undefined;
|
|
14
|
+
pre?: string | undefined;
|
|
15
|
+
message: string;
|
|
16
|
+
sticky?: boolean | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const TEMPLATE_PREFIX = '#template';
|
|
20
|
+
export const GOAL_PREFIX = '#goal';
|
|
21
|
+
|
|
22
|
+
export async function storeNoteTemplates(): Promise<void> {
|
|
23
|
+
const categoriesWithTemplates = await getCategoriesWithTemplates();
|
|
24
|
+
|
|
25
|
+
await storeTemplates({ categoriesWithTemplates, source: 'notes' });
|
|
26
|
+
|
|
27
|
+
await resetCategoryGoalDefsWithNoTemplates();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CategoryWithTemplateNotes = {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
templates: Template[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export async function checkTemplateNotes(): Promise<Notification> {
|
|
37
|
+
const categoryWithTemplates = await getCategoriesWithTemplates();
|
|
38
|
+
const schedules = await getActiveSchedules();
|
|
39
|
+
const scheduleNames = schedules.map(({ name }) => name);
|
|
40
|
+
const errors: string[] = [];
|
|
41
|
+
|
|
42
|
+
categoryWithTemplates.forEach(({ name, templates }) => {
|
|
43
|
+
templates.forEach(template => {
|
|
44
|
+
if (template.type === 'error') {
|
|
45
|
+
// Only show detailed error for adjustment-related errors
|
|
46
|
+
if (template.error && template.error.includes('adjustment')) {
|
|
47
|
+
errors.push(`${name}: ${template.line}\nError: ${template.error}`);
|
|
48
|
+
} else {
|
|
49
|
+
errors.push(`${name}: ${template.line}`);
|
|
50
|
+
}
|
|
51
|
+
} else if (
|
|
52
|
+
template.type === 'schedule' &&
|
|
53
|
+
!scheduleNames.includes(template.name)
|
|
54
|
+
) {
|
|
55
|
+
errors.push(`${name}: Schedule "${template.name}" does not exist`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (errors.length) {
|
|
61
|
+
return {
|
|
62
|
+
sticky: true,
|
|
63
|
+
message: 'There were errors interpreting some templates:',
|
|
64
|
+
pre: errors.join('\n\n'),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
type: 'message',
|
|
70
|
+
message: 'All templates passed! 🎉',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function getCategoriesWithTemplates(): Promise<
|
|
75
|
+
CategoryWithTemplateNotes[]
|
|
76
|
+
> {
|
|
77
|
+
const templatesForCategory: CategoryWithTemplateNotes[] = [];
|
|
78
|
+
const templateNotes = await getCategoriesWithTemplateNotes();
|
|
79
|
+
|
|
80
|
+
templateNotes.forEach(({ id, name, note }: CategoryWithTemplateNote) => {
|
|
81
|
+
if (!note) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const parsedTemplates: Template[] = [];
|
|
86
|
+
|
|
87
|
+
note.split('\n').forEach(line => {
|
|
88
|
+
const trimmedLine = line.substring(line.indexOf('#')).trim();
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
!trimmedLine.startsWith(TEMPLATE_PREFIX) &&
|
|
92
|
+
!trimmedLine.startsWith(GOAL_PREFIX)
|
|
93
|
+
) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const parsedTemplate: Template = parse(trimmedLine);
|
|
99
|
+
|
|
100
|
+
// Validate schedule adjustments
|
|
101
|
+
if (
|
|
102
|
+
(parsedTemplate.type === 'average' ||
|
|
103
|
+
parsedTemplate.type === 'schedule') &&
|
|
104
|
+
parsedTemplate.adjustment !== undefined
|
|
105
|
+
) {
|
|
106
|
+
if (parsedTemplate.adjustmentType === 'percent') {
|
|
107
|
+
if (
|
|
108
|
+
parsedTemplate.adjustment <= -100 ||
|
|
109
|
+
parsedTemplate.adjustment > 1000
|
|
110
|
+
) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Invalid adjustment percentage (${parsedTemplate.adjustment}%). Must be between -100% and 1000%`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
} else if (parsedTemplate.adjustmentType === 'fixed') {
|
|
116
|
+
//placeholder for potential validation of amount/fixed adjustments
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
parsedTemplates.push(parsedTemplate);
|
|
121
|
+
} catch (e: unknown) {
|
|
122
|
+
parsedTemplates.push({
|
|
123
|
+
type: 'error',
|
|
124
|
+
directive: 'error',
|
|
125
|
+
line,
|
|
126
|
+
error: (e as Error).message,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!parsedTemplates.length) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
templatesForCategory.push({
|
|
136
|
+
id,
|
|
137
|
+
name,
|
|
138
|
+
templates: parsedTemplates,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return templatesForCategory;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function prefixFromPriority(priority: number | null): string {
|
|
146
|
+
return priority === null ? TEMPLATE_PREFIX : `${TEMPLATE_PREFIX}-${priority}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function unparse(templates: Template[]): Promise<string> {
|
|
150
|
+
// Refill will be merged into the limit template if both exist
|
|
151
|
+
// Assumption: at most one limit and one refill template per category
|
|
152
|
+
const refill = templates.find(t => t.type === 'refill');
|
|
153
|
+
const withoutRefill = templates.filter(t => t.type !== 'refill');
|
|
154
|
+
|
|
155
|
+
return withoutRefill
|
|
156
|
+
.flatMap(template => {
|
|
157
|
+
if (template.type === 'error') {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (template.type === 'goal') {
|
|
162
|
+
return `${GOAL_PREFIX} ${template.amount}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const prefix = prefixFromPriority(template.priority);
|
|
166
|
+
|
|
167
|
+
switch (template.type) {
|
|
168
|
+
case 'simple': {
|
|
169
|
+
// Simple template syntax: #template[-prio] simple [monthly N] [limit]
|
|
170
|
+
let result = prefix;
|
|
171
|
+
if (template.monthly != null) {
|
|
172
|
+
result += ` ${template.monthly}`;
|
|
173
|
+
}
|
|
174
|
+
if (template.limit) {
|
|
175
|
+
result += ` ${limitToString(template.limit)}`;
|
|
176
|
+
}
|
|
177
|
+
return result.trim();
|
|
178
|
+
}
|
|
179
|
+
case 'schedule': {
|
|
180
|
+
// schedule syntax: #template[-prio] schedule <name> [full] [ [increase/decrease N%] ]
|
|
181
|
+
let result = `${prefix} schedule`;
|
|
182
|
+
if (template.full) {
|
|
183
|
+
result += ' full';
|
|
184
|
+
}
|
|
185
|
+
result += ` ${template.name}`;
|
|
186
|
+
if (template.adjustment !== undefined) {
|
|
187
|
+
const adj = template.adjustment;
|
|
188
|
+
const op = adj >= 0 ? 'increase' : 'decrease';
|
|
189
|
+
const val = Math.abs(adj);
|
|
190
|
+
const type = template.adjustmentType === 'percent' ? '%' : '';
|
|
191
|
+
result += ` [${op} ${val}${type}]`;
|
|
192
|
+
}
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
case 'percentage': {
|
|
196
|
+
// #template[-prio] <percent>% of [previous ]<category>
|
|
197
|
+
const prev = template.previous ? 'previous ' : '';
|
|
198
|
+
return `${prefix} ${trimTrailingZeros(template.percent)}% of ${prev}${template.category}`.trim();
|
|
199
|
+
}
|
|
200
|
+
case 'periodic': {
|
|
201
|
+
// #template[-prio] <amount> repeat every <n> <period>(s) starting <date> [limit]
|
|
202
|
+
const periodPart = periodToString(template.period);
|
|
203
|
+
let result = `${prefix} ${template.amount} repeat every ${periodPart} starting ${template.starting}`;
|
|
204
|
+
if (template.limit) {
|
|
205
|
+
result += ` ${limitToString(template.limit)}`;
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
case 'by':
|
|
210
|
+
case 'spend': {
|
|
211
|
+
// #template[-prio] <amount> by <month> [spend from <month>] [repeat every <...>]
|
|
212
|
+
let result = `${prefix} ${template.amount} by ${template.month}`;
|
|
213
|
+
if (template.type === 'spend' && template.from) {
|
|
214
|
+
result += ` spend from ${template.from}`;
|
|
215
|
+
}
|
|
216
|
+
// repeat info
|
|
217
|
+
if (template.annual !== undefined) {
|
|
218
|
+
const repeatInfo = repeatToString(template.annual, template.repeat);
|
|
219
|
+
if (repeatInfo) {
|
|
220
|
+
result += ` repeat every ${repeatInfo}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
case 'remainder': {
|
|
226
|
+
// #template remainder [weight] [limit]
|
|
227
|
+
let result = `${prefix} remainder`;
|
|
228
|
+
if (template.weight !== undefined && template.weight !== 1) {
|
|
229
|
+
result += ` ${template.weight}`;
|
|
230
|
+
}
|
|
231
|
+
if (template.limit) {
|
|
232
|
+
result += ` ${limitToString(template.limit)}`;
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
case 'average': {
|
|
237
|
+
let result = `${prefix} average ${template.numMonths} months`;
|
|
238
|
+
|
|
239
|
+
if (template.adjustment !== undefined) {
|
|
240
|
+
const adj = template.adjustment;
|
|
241
|
+
const op = adj >= 0 ? 'increase' : 'decrease';
|
|
242
|
+
const val = Math.abs(adj);
|
|
243
|
+
const type = template.adjustmentType === 'percent' ? '%' : '';
|
|
244
|
+
result += ` [${op} ${val}${type}]`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// #template average <numMonths> months [increase/decrease {number|number%}]
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
case 'copy': {
|
|
251
|
+
// #template copy from <lookBack> months ago [limit]
|
|
252
|
+
const result = `${prefix} copy from ${template.lookBack} months ago`;
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
case 'limit': {
|
|
256
|
+
if (!refill) {
|
|
257
|
+
// #template 0 up to <limit>
|
|
258
|
+
return `${prefix} 0 ${limitToString(template)}`;
|
|
259
|
+
}
|
|
260
|
+
// #template up to <limit>
|
|
261
|
+
const mergedPrefix = prefixFromPriority(refill.priority);
|
|
262
|
+
return `${mergedPrefix} ${limitToString(template)}`;
|
|
263
|
+
}
|
|
264
|
+
// No 'refill' support since a refill requires a limit
|
|
265
|
+
default:
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
.join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function limitToString(limit: {
|
|
273
|
+
amount: number;
|
|
274
|
+
hold: boolean;
|
|
275
|
+
period?: 'daily' | 'weekly' | 'monthly';
|
|
276
|
+
start?: string | undefined;
|
|
277
|
+
}): string {
|
|
278
|
+
switch (limit.period) {
|
|
279
|
+
case 'weekly': {
|
|
280
|
+
// Needs start date per grammar
|
|
281
|
+
const base = `up to ${limit.amount} per week starting ${limit.start}`;
|
|
282
|
+
return limit.hold ? `${base} hold` : base;
|
|
283
|
+
}
|
|
284
|
+
case 'daily': {
|
|
285
|
+
const base = `up to ${limit.amount} per day`;
|
|
286
|
+
return limit.hold ? `${base} hold` : base;
|
|
287
|
+
}
|
|
288
|
+
case 'monthly':
|
|
289
|
+
default: {
|
|
290
|
+
const base = `up to ${limit.amount}`;
|
|
291
|
+
return limit.hold ? `${base} hold` : base;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function periodToString(p: {
|
|
297
|
+
period: 'day' | 'week' | 'month' | 'year';
|
|
298
|
+
amount: number;
|
|
299
|
+
}): string {
|
|
300
|
+
const { period, amount } = p;
|
|
301
|
+
if (amount === 1) {
|
|
302
|
+
return period; // singular
|
|
303
|
+
}
|
|
304
|
+
// pluralize simple
|
|
305
|
+
return `${amount} ${period}s`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function repeatToString(annual?: boolean, repeat?: number): string | null {
|
|
309
|
+
if (annual === undefined) return null;
|
|
310
|
+
if (annual) {
|
|
311
|
+
if (!repeat || repeat === 1) return 'year';
|
|
312
|
+
return `${repeat} years`;
|
|
313
|
+
}
|
|
314
|
+
// monthly
|
|
315
|
+
if (!repeat || repeat === 1) return 'month';
|
|
316
|
+
return `${repeat} months`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function trimTrailingZeros(n: number): string {
|
|
320
|
+
const s = n.toString();
|
|
321
|
+
if (!s.includes('.')) return s;
|
|
322
|
+
return s.replace(/\.0+$/, '').replace(/(\.[0-9]*[1-9])0+$/, '$1');
|
|
323
|
+
}
|