dinary 1.2.1__tar.gz → 1.2.3__tar.gz
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.
- {dinary-1.2.1 → dinary-1.2.3}/PKG-INFO +1 -1
- dinary-1.2.3/src/dinary/__about__.py +1 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/__init__.py +2 -2
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/import_tasks.py +8 -8
- dinary-1.2.3/tasks/imports/income_extract.py +194 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/income_import.py +32 -0
- dinary-1.2.3/tests/imports/test_income_extract.py +330 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ProviderCard.vue +16 -3
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ProviderSheet.vue +25 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-provider-card.test.js +17 -1
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-provider-sheet.test.js +20 -0
- dinary-1.2.1/src/dinary/__about__.py +0 -1
- {dinary-1.2.1 → dinary-1.2.3}/.claudeignore +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.coveragerc +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/.env +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/import_sources.json +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/llm_providers.toml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/ci.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/docs.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/pip_publish.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/static.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.gitignore +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/.pre-commit-config.yaml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/AGENTS.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/CLAUDE.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/Dockerfile +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/LICENSE +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/activate.sh +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docker-compose.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/mkdocs.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/common/images/about.jpg +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/common/reference.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/cloudflare-setup.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/deploy-oracle.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/deploy-selfhost.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/development.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/google-sheets-setup.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/index.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/installation.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/operations.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/pwa-install.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/cloudflare-setup.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/deploy-oracle.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/deploy-selfhost.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/development.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/google-sheets-setup.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/index.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/installation.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/operations.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/pwa-install.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/taxonomy.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/invoke.yml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/pyproject.toml +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/pytest.ini +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/scripts/verup.sh +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/plans/analytics.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/architecture.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/catalog-api.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/classification-pipeline.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/currencies.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/frontend-cache.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/income-import.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/llm-providers.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/pwa-offline.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/receipt-fetching.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/sheets.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/sql-tool.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/stores.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/reference/timestamps.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/components.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/design-language.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/future-screens-guide.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/patterns.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/specs/ui/screens.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/__init__.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/exchange_rates.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/llm_storage.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/llmbroker.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/nbp.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/nbs.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/rate_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/serbian_receipt_parser.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/sheets_client.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/catalog.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_categories.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_errors.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_events.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_groups.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/expense_corrections.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/llm.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/qr_parser.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/rules.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/currencies.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/expense_corrections.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/llm.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/qr.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/receipts.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/rules.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/item_normalizer.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/persist.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/receipt_classifier.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/store_resolver.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/task.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/rate_prefetch/task.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/income_sheet_logging.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/logging_jobs.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/sheet_logging.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/sheets_write.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/task.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/config.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/catalog.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/classification_rules.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/currencies.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/db_migrations.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0001_initial_schema.rollback.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0001_initial_schema.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0002_exchange_rates_source_target.rollback.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0002_exchange_rates_source_target.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0003_app_currencies.rollback.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0003_app_currencies.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0004_receipt_pipeline.rollback.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0004_receipt_pipeline.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0005_income_logging.rollback.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0005_income_logging.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/receipts.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/__init__.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_category_by_name.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_existing_expense.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_month_expenses.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/insert_expense.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/insert_income.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/list_categories.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/list_incomes.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/logging_projection.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/resolve_mapping_for_year.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/seed_load_categories.sql +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql_loader.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/storage.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/main.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/sheets/sheet_mapping.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/src/dinary/sheets/sheets.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backup_retention.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backup_snapshots.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_replica.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_restore.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_status.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_yandex.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/restore_utils.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/db.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/deploy.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/build_docs.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/constants.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/dev.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/env.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/healthcheck.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/README.md +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/expense_import.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/report_2d_3d.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed_config.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed_derivation.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/verify_equivalence.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/verify_income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/receipt.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/report_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/report_tasks.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/verify_budget.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/verify_income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/server.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/setup.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/sql.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tasks/ssh_utils.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/_admin_catalog_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/_api_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_add.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_delete.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_meta.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_patch.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_llm.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_catalog.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_concurrency.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_conflict.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_currencies.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_delete_expense.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_delete_receipt.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_expenses_recent_patch.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_get_expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_post_expense.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_receipts.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_rules_approve.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_validation.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_receipt_pipeline_e2e.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_receipt_review.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_review_page_ux.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/conftest.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/_currency_rates_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_misc.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_nbp.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_resolve.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_rate_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_rate_prefetch_task.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/imports/test_expense_import.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/imports/test_seed_config.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/_catalog_writer_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/_ledger_repo_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_catalog_writer_invariants.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_catalog_writer_patch.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_income_db.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_catalog.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_insert.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_lookup.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_race.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_jobs.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_logging_projection.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_migrations.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/_report_2d_3d_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_aggregate.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_render.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_resolve.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_expenses.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_verify_budget.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_verify_income.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_classification_rules.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_item_normalizer.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_llm_storage.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_llmbroker.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_qr_parser.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_classification.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_classifier.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_parser.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_sql_loader.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_store_resolver.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheet_logging_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheet_mapping_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheets_helpers.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_income_drain.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_derive.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_drain.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_drain_one.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_parse.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_reload.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_resolve.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheets_read.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheets_rows.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_receipt_drain.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_receipt_pipeline.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_reclassify_receipts.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_restore.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_retention.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_status.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_yandex_setup.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_db.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_deploy.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_dev.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_imports.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_reports.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_restore_utils.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_server.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_server_receipt.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_setup_replica.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_ssh_utils.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_ssh_utils_scripts.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tools_sql.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/test_config.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/test_main.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/tests/test_webapp_api_contract.py +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/uv.lock +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/index.html +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/package-lock.json +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/package.json +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/apple-touch-icon-precomposed.png +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/apple-touch-icon.png +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/favicon.ico +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-180.png +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-192.png +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-512.png +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/public/manifest.json +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/App.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/_request.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/adminLlm.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/catalog.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/currencies.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/expenseCorrections.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/expenses.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/income.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/receipts.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/review.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/assets/base.css +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/BaseModal.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/BaseSheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CatalogSelectField.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CategoryQuickPicks.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CategorySheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ConfirmDeleteSheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CorrectionSheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CurrencyAmountRow.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CurrencyPicker.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseEditSheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseForm.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseRow.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/HeaderSegmented.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/HealthSummaryCard.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IconBtn.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeEditSheet.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeForm.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeRow.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/InlineCreateEvent.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/InlineCreateRow.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/KeyboardSaveBar.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ManageList.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/QrScanner.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/QueueModal.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ReceiptCascadeCard.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/RuleRow.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ScopeSelector.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/StatusDot.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/TagPicker.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/addResult.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/catalogManage.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/flushQueue.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/flushReceiptQueue.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/receipt.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useExpenseDeleteFlow.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useKeyboardVisible.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useOnline.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useStaleCache.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useSwipeRow.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/zbar.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/legacy-pwa-cleanup.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/main.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/modals/EditModal.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/catalog.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/currency.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/frequentCategories.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/income.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/llm.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/queue.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/receiptQueue.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/review.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/toast.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/AddView.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/IncomeView.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/LLMView.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/ReviewView.vue +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/BaseSheet.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CategoryQuickPicks.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CategorySheet.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CurrencyAmountRow.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ExpenseEditSheet.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ExpenseRow.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ReceiptCascadeCard.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ScopeSelector.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-adminLlm.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-catalog.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-currencies.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-expenses.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-income.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-receipts.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-request.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-review.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-app.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-catalog-select-field.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-correction-sheet.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-currency-picker.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-edit-modal.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-expense-edit-sheet.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-expense-form.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-header-segmented.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-health-summary-card.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-inline-create-event.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-inline-create-row.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-manage-list.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-queue-modal.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-review-view.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-rule-row.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-status-dot.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-tag-picker.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-add-result.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-catalog-manage.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-flush-queue.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-flush-receipt-queue.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-keyboard-visible.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-receipt.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-stale-cache.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-use-online.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/frequentCategories.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/legacy-pwa-cleanup.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/setup.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-catalog.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-currency.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-income.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-llm.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-queue.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-receipt-queue-durability.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-receipt-queue.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-review.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-toast.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/useExpenseDeleteFlow.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/useSwipeRow.test.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/vite.config.js +0 -0
- {dinary-1.2.1 → dinary-1.2.3}/webapp/vitest.config.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dinary
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.3
|
|
4
4
|
Summary: Server for [Dinary - your dinar diary](https://github.com/andgineer/dinary). Track expenses, scan receipts, analyze spending with AI
|
|
5
5
|
Project-URL: Homepage, https://andgineer.github.io/dinary/
|
|
6
6
|
Project-URL: Documentation, https://andgineer.github.io/dinary/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.3"
|
|
@@ -20,10 +20,10 @@ from .devtools.constants import ALLOWED_VERSION_TYPES
|
|
|
20
20
|
from .devtools.dev import build_static, dev, pre, reqs, test, uv, ver_task_factory, version
|
|
21
21
|
from .healthcheck import healthcheck
|
|
22
22
|
from .imports.import_tasks import (
|
|
23
|
-
export_income_original,
|
|
24
23
|
import_budget,
|
|
25
24
|
import_budget_all,
|
|
26
25
|
import_catalog,
|
|
26
|
+
import_extract_income,
|
|
27
27
|
import_income,
|
|
28
28
|
import_income_all,
|
|
29
29
|
import_report_2d_3d,
|
|
@@ -48,11 +48,11 @@ __all__ = [
|
|
|
48
48
|
"dev",
|
|
49
49
|
"docs_task_factory",
|
|
50
50
|
"healthcheck",
|
|
51
|
-
"export_income_original",
|
|
52
51
|
"import_budget",
|
|
53
52
|
"import_budget_all",
|
|
54
53
|
"import_catalog",
|
|
55
54
|
"import_config",
|
|
55
|
+
"import_extract_income",
|
|
56
56
|
"import_income",
|
|
57
57
|
"import_income_all",
|
|
58
58
|
"import_report_2d_3d",
|
|
@@ -7,7 +7,7 @@ from datetime import datetime as _dt
|
|
|
7
7
|
|
|
8
8
|
from invoke import task
|
|
9
9
|
|
|
10
|
-
from tasks.imports import
|
|
10
|
+
from tasks.imports import income_extract
|
|
11
11
|
from tasks.imports import report_2d_3d as _report_2d_3d_module
|
|
12
12
|
from tasks.reports import verify_budget, verify_income
|
|
13
13
|
from tasks.ssh_utils import remote_snapshot_cmd, ssh_capture_bytes, ssh_json, ssh_run
|
|
@@ -250,16 +250,16 @@ def verify_income_equivalence_all(c, json=False): # noqa: A002
|
|
|
250
250
|
sys.exit(verify_income.exit_code_for_batch(results))
|
|
251
251
|
|
|
252
252
|
|
|
253
|
-
@task(name="
|
|
254
|
-
def
|
|
255
|
-
"""
|
|
253
|
+
@task(name="import-extract-income")
|
|
254
|
+
def import_extract_income(c, output=""):
|
|
255
|
+
"""Extract individual income records from all sheets in original currency to JSON.
|
|
256
256
|
|
|
257
257
|
Reads every registered income source from .deploy/import_sources.json and
|
|
258
|
-
writes
|
|
259
|
-
Default output path: data/
|
|
258
|
+
writes one entry per sheet row with predicted income_month.
|
|
259
|
+
Default output path: data/income_extract.json. Override with --output.
|
|
260
260
|
"""
|
|
261
|
-
dest = Path(output) if output else
|
|
262
|
-
count =
|
|
261
|
+
dest = Path(output) if output else income_extract.DEFAULT_OUTPUT
|
|
262
|
+
count = income_extract.export_to_file(dest)
|
|
263
263
|
print(f"Wrote {count} entries to {dest}")
|
|
264
264
|
|
|
265
265
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Extract individual income records from Google Sheets in original currency.
|
|
2
|
+
|
|
3
|
+
Reads every registered income source from ``.deploy/import_sources.json``
|
|
4
|
+
and returns one entry per sheet row. Amounts are NOT summed by month.
|
|
5
|
+
|
|
6
|
+
Each entry carries the raw payment date and a predicted ``income_month``:
|
|
7
|
+
- day <= 25 -> previous month (salary paid at start of month for prior month)
|
|
8
|
+
- day > 25 -> current month (late payment belonging to the same month)
|
|
9
|
+
|
|
10
|
+
Output JSON:
|
|
11
|
+
|
|
12
|
+
.. code-block:: json
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"generated_at": "2026-05-31",
|
|
16
|
+
"entries": [
|
|
17
|
+
{
|
|
18
|
+
"year": 2019, "month": 1, "day": 10,
|
|
19
|
+
"amount": "85000.00", "currency": "RUB",
|
|
20
|
+
"income_year": 2018, "income_month": 12
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
``inv import-extract-income`` is the operator entry point.
|
|
26
|
+
Can also be run with ``uv run python -m tasks.imports.income_extract``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import date
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from dinary.adapters.sheets_client import get_sheet
|
|
37
|
+
from dinary.config import IMPORT_SOURCES_DOC_HINT, read_import_sources
|
|
38
|
+
from tasks.imports.expense_import import MONTHS_IN_YEAR
|
|
39
|
+
from tasks.imports.income_import import (
|
|
40
|
+
INCOME_LAYOUTS,
|
|
41
|
+
IncomeLayout,
|
|
42
|
+
_cell,
|
|
43
|
+
_parse_amount,
|
|
44
|
+
_parse_full_date,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
DEFAULT_OUTPUT = Path("data/income_extract.json")
|
|
50
|
+
|
|
51
|
+
INCOME_MONTH_LATE_CUTOFF = 25
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _predict_income_month(year: int, month: int, day: int) -> tuple[int, int]:
|
|
55
|
+
"""Return (income_year, income_month) for a payment on the given date.
|
|
56
|
+
|
|
57
|
+
day <= 25 -> previous month; day > 25 -> current month.
|
|
58
|
+
"""
|
|
59
|
+
if day > INCOME_MONTH_LATE_CUTOFF:
|
|
60
|
+
return year, month
|
|
61
|
+
return (year - 1, 12) if month == 1 else (year, month - 1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_income_records(
|
|
65
|
+
year: int,
|
|
66
|
+
spreadsheet_id: str,
|
|
67
|
+
worksheet_name: str,
|
|
68
|
+
layout: IncomeLayout,
|
|
69
|
+
) -> list[dict]:
|
|
70
|
+
"""Read one Income/Balance worksheet; return one dict per income row.
|
|
71
|
+
|
|
72
|
+
Each row is a separate entry with raw payment date and predicted
|
|
73
|
+
income_year/income_month. No aggregation.
|
|
74
|
+
"""
|
|
75
|
+
ss = get_sheet(spreadsheet_id)
|
|
76
|
+
ws = ss.worksheet(worksheet_name)
|
|
77
|
+
all_values = ws.get_all_values()
|
|
78
|
+
|
|
79
|
+
records: list[dict] = []
|
|
80
|
+
|
|
81
|
+
for row_idx in range(layout.header_rows, len(all_values)):
|
|
82
|
+
row = all_values[row_idx]
|
|
83
|
+
parsed = _parse_full_date(_cell(row, layout.col_date))
|
|
84
|
+
if parsed is None:
|
|
85
|
+
continue
|
|
86
|
+
row_year, month, day = parsed
|
|
87
|
+
if row_year != year:
|
|
88
|
+
continue
|
|
89
|
+
if not 1 <= month <= MONTHS_IN_YEAR:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
amount = _parse_amount(_cell(row, layout.col_amount))
|
|
93
|
+
if amount is None:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
currency = layout.currency
|
|
97
|
+
if (
|
|
98
|
+
layout.transition_month is not None
|
|
99
|
+
and layout.transition_currency is not None
|
|
100
|
+
and month >= layout.transition_month
|
|
101
|
+
):
|
|
102
|
+
currency = layout.transition_currency
|
|
103
|
+
|
|
104
|
+
income_year, income_month = _predict_income_month(year, month, day)
|
|
105
|
+
records.append(
|
|
106
|
+
{
|
|
107
|
+
"year": year,
|
|
108
|
+
"month": month,
|
|
109
|
+
"day": day,
|
|
110
|
+
"amount": format(amount, "f"),
|
|
111
|
+
"currency": currency,
|
|
112
|
+
"income_year": income_year,
|
|
113
|
+
"income_month": income_month,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return records
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_all_years() -> list[dict]:
|
|
121
|
+
"""Return a flat list of individual income records across all registered years."""
|
|
122
|
+
sources = read_import_sources()
|
|
123
|
+
entries: list[dict] = []
|
|
124
|
+
|
|
125
|
+
for source in sorted(sources, key=lambda s: s.year):
|
|
126
|
+
if not source.income_worksheet_name:
|
|
127
|
+
continue
|
|
128
|
+
if not source.income_layout_key or source.income_layout_key not in INCOME_LAYOUTS:
|
|
129
|
+
logger.warning(
|
|
130
|
+
"Year %d: unknown income_layout_key %r — skipping. %s",
|
|
131
|
+
source.year,
|
|
132
|
+
source.income_layout_key,
|
|
133
|
+
IMPORT_SOURCES_DOC_HINT,
|
|
134
|
+
)
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
layout = INCOME_LAYOUTS[source.income_layout_key]
|
|
138
|
+
logger.info(
|
|
139
|
+
"Extracting year %d from worksheet %r",
|
|
140
|
+
source.year,
|
|
141
|
+
source.income_worksheet_name,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
records = extract_income_records(
|
|
146
|
+
source.year,
|
|
147
|
+
source.spreadsheet_id,
|
|
148
|
+
source.income_worksheet_name,
|
|
149
|
+
layout,
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
logger.exception("Failed to read year %d — skipping", source.year)
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
entries.extend(sorted(records, key=lambda r: (r["month"], r["day"])))
|
|
156
|
+
|
|
157
|
+
return entries
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def export_to_file(output: Path) -> int:
|
|
161
|
+
"""Extract all income records and write to ``output``.
|
|
162
|
+
|
|
163
|
+
Returns the number of entries written. Raises on I/O errors.
|
|
164
|
+
"""
|
|
165
|
+
entries = extract_all_years()
|
|
166
|
+
payload = {
|
|
167
|
+
"generated_at": str(date.today()),
|
|
168
|
+
"entries": entries,
|
|
169
|
+
}
|
|
170
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
172
|
+
return len(entries)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
description=(
|
|
178
|
+
"Extract individual income records from all sheets in original currency to JSON."
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--output",
|
|
183
|
+
default=str(DEFAULT_OUTPUT),
|
|
184
|
+
help=f"destination JSON file (default: {DEFAULT_OUTPUT})",
|
|
185
|
+
)
|
|
186
|
+
args = parser.parse_args(argv)
|
|
187
|
+
output = Path(args.output)
|
|
188
|
+
count = export_to_file(output)
|
|
189
|
+
print(f"Wrote {count} entries to {output}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
sys.exit(main())
|
|
@@ -106,6 +106,38 @@ def _parse_date(raw: str) -> tuple[int, int] | None:
|
|
|
106
106
|
return None
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _parse_full_date(raw: str) -> tuple[int, int, int] | None:
|
|
110
|
+
"""Extract (year, month, day) from a date string. Returns None if unparseable."""
|
|
111
|
+
if not raw:
|
|
112
|
+
return None
|
|
113
|
+
m = _LOOSE_DATE_RE.match(raw)
|
|
114
|
+
if m:
|
|
115
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
116
|
+
for fmt in _DATE_FORMATS:
|
|
117
|
+
try:
|
|
118
|
+
dt = datetime.strptime(raw, fmt) # noqa: DTZ007
|
|
119
|
+
return dt.year, dt.month, dt.day
|
|
120
|
+
except ValueError:
|
|
121
|
+
continue
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_full_date(raw: str) -> tuple[int, int, int] | None:
|
|
126
|
+
"""Extract (year, month, day) from a date string. Returns None if unparseable."""
|
|
127
|
+
if not raw:
|
|
128
|
+
return None
|
|
129
|
+
m = _LOOSE_DATE_RE.match(raw)
|
|
130
|
+
if m:
|
|
131
|
+
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
132
|
+
for fmt in _DATE_FORMATS:
|
|
133
|
+
try:
|
|
134
|
+
dt = datetime.strptime(raw, fmt) # noqa: DTZ007
|
|
135
|
+
return dt.year, dt.month, dt.day
|
|
136
|
+
except ValueError:
|
|
137
|
+
continue
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
109
141
|
def _convert_to_accounting_from_cache(
|
|
110
142
|
amount: Decimal,
|
|
111
143
|
currency: str,
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Tests for income_extract: individual-record extraction from sheets."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import tasks.imports.income_extract as _mod
|
|
9
|
+
from dinary.config import ImportSourceRow
|
|
10
|
+
from tasks.imports.income_extract import (
|
|
11
|
+
_predict_income_month,
|
|
12
|
+
export_to_file,
|
|
13
|
+
extract_all_years,
|
|
14
|
+
extract_income_records,
|
|
15
|
+
)
|
|
16
|
+
from tasks.imports.income_import import INCOME_LAYOUTS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Helpers
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ws(rows: list[list[str]]) -> MagicMock:
|
|
25
|
+
ws = MagicMock()
|
|
26
|
+
ws.get_all_values.return_value = rows
|
|
27
|
+
return ws
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ss(ws: MagicMock) -> MagicMock:
|
|
31
|
+
ss = MagicMock()
|
|
32
|
+
ss.worksheet.return_value = ws
|
|
33
|
+
return ss
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# _predict_income_month
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestPredictIncomeMonth:
|
|
42
|
+
def test_day_25_is_prev_month(self):
|
|
43
|
+
assert _predict_income_month(2024, 3, 25) == (2024, 2)
|
|
44
|
+
|
|
45
|
+
def test_day_1_is_prev_month(self):
|
|
46
|
+
assert _predict_income_month(2024, 5, 1) == (2024, 4)
|
|
47
|
+
|
|
48
|
+
def test_day_26_is_current_month(self):
|
|
49
|
+
assert _predict_income_month(2024, 3, 26) == (2024, 3)
|
|
50
|
+
|
|
51
|
+
def test_day_31_is_current_month(self):
|
|
52
|
+
assert _predict_income_month(2024, 1, 31) == (2024, 1)
|
|
53
|
+
|
|
54
|
+
def test_january_day_le_25_wraps_to_december_prev_year(self):
|
|
55
|
+
assert _predict_income_month(2024, 1, 10) == (2023, 12)
|
|
56
|
+
|
|
57
|
+
def test_january_day_26_stays_january(self):
|
|
58
|
+
assert _predict_income_month(2024, 1, 26) == (2024, 1)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# extract_income_records
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestExtractIncomeRecords:
|
|
67
|
+
def test_returns_separate_record_per_row(self):
|
|
68
|
+
layout = INCOME_LAYOUTS["balance_rub"]
|
|
69
|
+
rows = [
|
|
70
|
+
["date", "amount"],
|
|
71
|
+
["2019-01-15", "50000"],
|
|
72
|
+
["2019-01-20", "35000"],
|
|
73
|
+
]
|
|
74
|
+
ss = _ss(_ws(rows))
|
|
75
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
76
|
+
result = extract_income_records(2019, "sid", "Income", layout)
|
|
77
|
+
|
|
78
|
+
assert len(result) == 2
|
|
79
|
+
assert result[0]["amount"] == "50000"
|
|
80
|
+
assert result[1]["amount"] == "35000"
|
|
81
|
+
|
|
82
|
+
def test_record_fields(self):
|
|
83
|
+
layout = INCOME_LAYOUTS["balance_rub"]
|
|
84
|
+
rows = [["date", "amount"], ["2019-03-10", "90000"]]
|
|
85
|
+
ss = _ss(_ws(rows))
|
|
86
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
87
|
+
result = extract_income_records(2019, "sid", "Income", layout)
|
|
88
|
+
|
|
89
|
+
rec = result[0]
|
|
90
|
+
assert rec["year"] == 2019
|
|
91
|
+
assert rec["month"] == 3
|
|
92
|
+
assert rec["day"] == 10
|
|
93
|
+
assert rec["currency"] == "RUB"
|
|
94
|
+
assert rec["income_year"] == 2019
|
|
95
|
+
assert rec["income_month"] == 2 # day 10 <= 25 -> prev month
|
|
96
|
+
|
|
97
|
+
def test_day_26_maps_to_current_month(self):
|
|
98
|
+
layout = INCOME_LAYOUTS["balance_rsd"]
|
|
99
|
+
rows = [["date", "amount"], ["2023-05-26", "120000"]]
|
|
100
|
+
ss = _ss(_ws(rows))
|
|
101
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
102
|
+
result = extract_income_records(2023, "sid", "Income", layout)
|
|
103
|
+
|
|
104
|
+
assert result[0]["income_month"] == 5
|
|
105
|
+
assert result[0]["income_year"] == 2023
|
|
106
|
+
|
|
107
|
+
def test_transition_layout_currency(self):
|
|
108
|
+
layout = INCOME_LAYOUTS["balance_rub_rsd"]
|
|
109
|
+
rows = [
|
|
110
|
+
["date", "amount"],
|
|
111
|
+
["2022-07-10", "60000"],
|
|
112
|
+
["2022-08-10", "120000"],
|
|
113
|
+
]
|
|
114
|
+
ss = _ss(_ws(rows))
|
|
115
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
116
|
+
result = extract_income_records(2022, "sid", "Balance", layout)
|
|
117
|
+
|
|
118
|
+
assert result[0]["currency"] == "RUB"
|
|
119
|
+
assert result[1]["currency"] == "RSD"
|
|
120
|
+
|
|
121
|
+
def test_skips_rows_from_other_years(self):
|
|
122
|
+
layout = INCOME_LAYOUTS["balance_rub"]
|
|
123
|
+
rows = [
|
|
124
|
+
["date", "amount"],
|
|
125
|
+
["2019-06-01", "10000"],
|
|
126
|
+
["2020-06-01", "99999"],
|
|
127
|
+
]
|
|
128
|
+
ss = _ss(_ws(rows))
|
|
129
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
130
|
+
result = extract_income_records(2019, "sid", "Balance", layout)
|
|
131
|
+
|
|
132
|
+
assert len(result) == 1
|
|
133
|
+
assert result[0]["month"] == 6
|
|
134
|
+
|
|
135
|
+
def test_skips_blank_and_zero_amounts(self):
|
|
136
|
+
layout = INCOME_LAYOUTS["balance_rsd"]
|
|
137
|
+
rows = [
|
|
138
|
+
["date", "amount"],
|
|
139
|
+
["2023-03-01", ""],
|
|
140
|
+
["2023-03-05", "0"],
|
|
141
|
+
["2023-03-10", "75000"],
|
|
142
|
+
]
|
|
143
|
+
ss = _ss(_ws(rows))
|
|
144
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
145
|
+
result = extract_income_records(2023, "sid", "Income", layout)
|
|
146
|
+
|
|
147
|
+
assert len(result) == 1
|
|
148
|
+
assert result[0]["amount"] == "75000"
|
|
149
|
+
|
|
150
|
+
def test_skips_unparseable_dates(self):
|
|
151
|
+
layout = INCOME_LAYOUTS["balance_rsd"]
|
|
152
|
+
rows = [
|
|
153
|
+
["date", "amount"],
|
|
154
|
+
["not-a-date", "50000"],
|
|
155
|
+
["2023-04-01", "50000"],
|
|
156
|
+
]
|
|
157
|
+
ss = _ss(_ws(rows))
|
|
158
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
159
|
+
result = extract_income_records(2023, "sid", "Income", layout)
|
|
160
|
+
|
|
161
|
+
assert len(result) == 1
|
|
162
|
+
assert result[0]["month"] == 4
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# extract_all_years
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestExtractAllYears:
|
|
171
|
+
@pytest.fixture(autouse=True)
|
|
172
|
+
def _stub_sources(self, monkeypatch):
|
|
173
|
+
sources = [
|
|
174
|
+
ImportSourceRow(
|
|
175
|
+
year=2021,
|
|
176
|
+
spreadsheet_id="sid21",
|
|
177
|
+
worksheet_name="Budget",
|
|
178
|
+
income_worksheet_name="Balance",
|
|
179
|
+
income_layout_key="balance_rub",
|
|
180
|
+
),
|
|
181
|
+
ImportSourceRow(
|
|
182
|
+
year=2023,
|
|
183
|
+
spreadsheet_id="sid23",
|
|
184
|
+
worksheet_name="Budget",
|
|
185
|
+
income_worksheet_name="Income",
|
|
186
|
+
income_layout_key="balance_rsd",
|
|
187
|
+
),
|
|
188
|
+
ImportSourceRow(
|
|
189
|
+
year=2025,
|
|
190
|
+
spreadsheet_id="sid25",
|
|
191
|
+
worksheet_name="Budget",
|
|
192
|
+
income_worksheet_name="",
|
|
193
|
+
income_layout_key="",
|
|
194
|
+
),
|
|
195
|
+
]
|
|
196
|
+
monkeypatch.setattr(_mod, "read_import_sources", lambda: list(sources))
|
|
197
|
+
|
|
198
|
+
def _make_get_sheet(self, data_by_sid: dict[str, list[list[str]]]):
|
|
199
|
+
def _get_sheet(spreadsheet_id: str):
|
|
200
|
+
return _ss(_ws(data_by_sid[spreadsheet_id]))
|
|
201
|
+
|
|
202
|
+
return _get_sheet
|
|
203
|
+
|
|
204
|
+
def test_returns_entries_for_years_with_income(self):
|
|
205
|
+
data = {
|
|
206
|
+
"sid21": [["date", "amount"], ["2021-05-10", "90000"]],
|
|
207
|
+
"sid23": [["date", "amount"], ["2023-06-10", "150000"]],
|
|
208
|
+
}
|
|
209
|
+
with patch(
|
|
210
|
+
"tasks.imports.income_extract.get_sheet", side_effect=self._make_get_sheet(data)
|
|
211
|
+
):
|
|
212
|
+
entries = extract_all_years()
|
|
213
|
+
|
|
214
|
+
assert len(entries) == 2
|
|
215
|
+
years = {e["year"] for e in entries}
|
|
216
|
+
assert years == {2021, 2023}
|
|
217
|
+
|
|
218
|
+
def test_skips_year_without_income_worksheet(self):
|
|
219
|
+
data = {
|
|
220
|
+
"sid21": [["date", "amount"], ["2021-01-01", "50000"]],
|
|
221
|
+
"sid23": [["date", "amount"], ["2023-01-01", "100000"]],
|
|
222
|
+
}
|
|
223
|
+
with patch(
|
|
224
|
+
"tasks.imports.income_extract.get_sheet", side_effect=self._make_get_sheet(data)
|
|
225
|
+
):
|
|
226
|
+
entries = extract_all_years()
|
|
227
|
+
|
|
228
|
+
assert all(e["year"] != 2025 for e in entries)
|
|
229
|
+
|
|
230
|
+
def test_sheet_failure_skips_year_continues(self):
|
|
231
|
+
call_count = 0
|
|
232
|
+
|
|
233
|
+
def _get_sheet(spreadsheet_id: str):
|
|
234
|
+
nonlocal call_count
|
|
235
|
+
call_count += 1
|
|
236
|
+
if spreadsheet_id == "sid21":
|
|
237
|
+
raise OSError("network error")
|
|
238
|
+
return _ss(_ws([["date", "amount"], ["2023-03-01", "120000"]]))
|
|
239
|
+
|
|
240
|
+
with patch("tasks.imports.income_extract.get_sheet", side_effect=_get_sheet):
|
|
241
|
+
entries = extract_all_years()
|
|
242
|
+
|
|
243
|
+
assert call_count == 2
|
|
244
|
+
assert all(e["year"] != 2021 for e in entries)
|
|
245
|
+
assert any(e["year"] == 2023 for e in entries)
|
|
246
|
+
|
|
247
|
+
def test_entries_sorted_by_month_then_day_within_year(self):
|
|
248
|
+
data = {
|
|
249
|
+
"sid21": [
|
|
250
|
+
["date", "amount"],
|
|
251
|
+
["2021-03-15", "1000"],
|
|
252
|
+
["2021-01-20", "2000"],
|
|
253
|
+
["2021-01-05", "3000"],
|
|
254
|
+
],
|
|
255
|
+
"sid23": [["date", "amount"], ["2023-02-01", "4000"]],
|
|
256
|
+
}
|
|
257
|
+
with patch(
|
|
258
|
+
"tasks.imports.income_extract.get_sheet", side_effect=self._make_get_sheet(data)
|
|
259
|
+
):
|
|
260
|
+
entries = extract_all_years()
|
|
261
|
+
|
|
262
|
+
yr21 = [e for e in entries if e["year"] == 2021]
|
|
263
|
+
assert yr21[0]["month"] == 1
|
|
264
|
+
assert yr21[0]["day"] == 5
|
|
265
|
+
assert yr21[1]["month"] == 1
|
|
266
|
+
assert yr21[1]["day"] == 20
|
|
267
|
+
assert yr21[2]["month"] == 3
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------------------
|
|
271
|
+
# export_to_file
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class TestExportToFile:
|
|
276
|
+
def test_writes_valid_json_with_all_fields(self, tmp_path, monkeypatch):
|
|
277
|
+
sources = [
|
|
278
|
+
ImportSourceRow(
|
|
279
|
+
year=2024,
|
|
280
|
+
spreadsheet_id="sid24",
|
|
281
|
+
worksheet_name="Budget",
|
|
282
|
+
income_worksheet_name="Income",
|
|
283
|
+
income_layout_key="income_rsd",
|
|
284
|
+
),
|
|
285
|
+
]
|
|
286
|
+
monkeypatch.setattr(_mod, "read_import_sources", lambda: list(sources))
|
|
287
|
+
ws = _ws([["date", "amount"], ["2024-04-10", "300000"]])
|
|
288
|
+
ss = _ss(ws)
|
|
289
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
290
|
+
dest = tmp_path / "out.json"
|
|
291
|
+
count = export_to_file(dest)
|
|
292
|
+
|
|
293
|
+
assert count == 1
|
|
294
|
+
payload = json.loads(dest.read_text())
|
|
295
|
+
assert "generated_at" in payload
|
|
296
|
+
entry = payload["entries"][0]
|
|
297
|
+
assert entry["year"] == 2024
|
|
298
|
+
assert entry["month"] == 4
|
|
299
|
+
assert entry["day"] == 10
|
|
300
|
+
assert entry["currency"] == "RSD"
|
|
301
|
+
assert entry["income_year"] == 2024
|
|
302
|
+
assert entry["income_month"] == 3 # day 10 <= 25 -> prev month
|
|
303
|
+
|
|
304
|
+
def test_creates_parent_directory(self, tmp_path, monkeypatch):
|
|
305
|
+
monkeypatch.setattr(_mod, "read_import_sources", lambda: [])
|
|
306
|
+
dest = tmp_path / "nested" / "dir" / "income.json"
|
|
307
|
+
export_to_file(dest)
|
|
308
|
+
assert dest.exists()
|
|
309
|
+
|
|
310
|
+
def test_amount_is_decimal_string(self, tmp_path, monkeypatch):
|
|
311
|
+
sources = [
|
|
312
|
+
ImportSourceRow(
|
|
313
|
+
year=2023,
|
|
314
|
+
spreadsheet_id="sid23",
|
|
315
|
+
worksheet_name="Budget",
|
|
316
|
+
income_worksheet_name="Income",
|
|
317
|
+
income_layout_key="balance_rsd",
|
|
318
|
+
),
|
|
319
|
+
]
|
|
320
|
+
monkeypatch.setattr(_mod, "read_import_sources", lambda: list(sources))
|
|
321
|
+
ws = _ws([["date", "amount"], ["2023-01-10", "123456.78"]])
|
|
322
|
+
ss = _ss(ws)
|
|
323
|
+
with patch("tasks.imports.income_extract.get_sheet", return_value=ss):
|
|
324
|
+
dest = tmp_path / "out.json"
|
|
325
|
+
export_to_file(dest)
|
|
326
|
+
|
|
327
|
+
payload = json.loads(dest.read_text())
|
|
328
|
+
amount = payload["entries"][0]["amount"]
|
|
329
|
+
assert isinstance(amount, str)
|
|
330
|
+
assert "." in amount
|
|
@@ -41,6 +41,18 @@ const latencyColor = computed(() => {
|
|
|
41
41
|
if (ms == null) return "var(--muted)";
|
|
42
42
|
return ms > 3000 ? "var(--warning)" : "var(--muted)";
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
const errorMessage = computed(() => {
|
|
46
|
+
const raw = props.provider.last_error_detail;
|
|
47
|
+
if (!raw) return null;
|
|
48
|
+
try {
|
|
49
|
+
let parsed = JSON.parse(raw);
|
|
50
|
+
if (Array.isArray(parsed)) parsed = parsed[0];
|
|
51
|
+
return parsed?.error?.message ?? raw;
|
|
52
|
+
} catch {
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
44
56
|
</script>
|
|
45
57
|
|
|
46
58
|
<template>
|
|
@@ -63,7 +75,7 @@ const latencyColor = computed(() => {
|
|
|
63
75
|
<span v-if="rateLimitSecsLeft > 0" class="rate-limit-pill">{{ rateLimitSecsLeft }}s</span>
|
|
64
76
|
</div>
|
|
65
77
|
<div class="card-model">{{ provider.model }}</div>
|
|
66
|
-
<div v-if="
|
|
78
|
+
<div v-if="errorMessage" class="error-detail">{{ errorMessage }}</div>
|
|
67
79
|
</div>
|
|
68
80
|
|
|
69
81
|
<div class="usage-row">
|
|
@@ -195,9 +207,10 @@ const latencyColor = computed(() => {
|
|
|
195
207
|
font-size: 0.7rem;
|
|
196
208
|
color: var(--danger, #ef4444);
|
|
197
209
|
margin-top: 0.2rem;
|
|
210
|
+
display: -webkit-box;
|
|
211
|
+
-webkit-line-clamp: 2;
|
|
212
|
+
-webkit-box-orient: vertical;
|
|
198
213
|
overflow: hidden;
|
|
199
|
-
text-overflow: ellipsis;
|
|
200
|
-
white-space: nowrap;
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
.card-model {
|