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.
Files changed (422) hide show
  1. {dinary-1.2.1 → dinary-1.2.3}/PKG-INFO +1 -1
  2. dinary-1.2.3/src/dinary/__about__.py +1 -0
  3. {dinary-1.2.1 → dinary-1.2.3}/tasks/__init__.py +2 -2
  4. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/import_tasks.py +8 -8
  5. dinary-1.2.3/tasks/imports/income_extract.py +194 -0
  6. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/income_import.py +32 -0
  7. dinary-1.2.3/tests/imports/test_income_extract.py +330 -0
  8. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ProviderCard.vue +16 -3
  9. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ProviderSheet.vue +25 -0
  10. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-provider-card.test.js +17 -1
  11. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-provider-sheet.test.js +20 -0
  12. dinary-1.2.1/src/dinary/__about__.py +0 -1
  13. {dinary-1.2.1 → dinary-1.2.3}/.claudeignore +0 -0
  14. {dinary-1.2.1 → dinary-1.2.3}/.coveragerc +0 -0
  15. {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/.env +0 -0
  16. {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/README.md +0 -0
  17. {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/import_sources.json +0 -0
  18. {dinary-1.2.1 → dinary-1.2.3}/.deploy.example/llm_providers.toml +0 -0
  19. {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/ci.yml +0 -0
  20. {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/docs.yml +0 -0
  21. {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/pip_publish.yml +0 -0
  22. {dinary-1.2.1 → dinary-1.2.3}/.github/workflows/static.yml +0 -0
  23. {dinary-1.2.1 → dinary-1.2.3}/.gitignore +0 -0
  24. {dinary-1.2.1 → dinary-1.2.3}/.pre-commit-config.yaml +0 -0
  25. {dinary-1.2.1 → dinary-1.2.3}/AGENTS.md +0 -0
  26. {dinary-1.2.1 → dinary-1.2.3}/CLAUDE.md +0 -0
  27. {dinary-1.2.1 → dinary-1.2.3}/Dockerfile +0 -0
  28. {dinary-1.2.1 → dinary-1.2.3}/LICENSE +0 -0
  29. {dinary-1.2.1 → dinary-1.2.3}/README.md +0 -0
  30. {dinary-1.2.1 → dinary-1.2.3}/activate.sh +0 -0
  31. {dinary-1.2.1 → dinary-1.2.3}/docker-compose.yml +0 -0
  32. {dinary-1.2.1 → dinary-1.2.3}/docs/mkdocs.yml +0 -0
  33. {dinary-1.2.1 → dinary-1.2.3}/docs/src/common/images/about.jpg +0 -0
  34. {dinary-1.2.1 → dinary-1.2.3}/docs/src/common/reference.md +0 -0
  35. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/cloudflare-setup.md +0 -0
  36. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/deploy-oracle.md +0 -0
  37. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/deploy-selfhost.md +0 -0
  38. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/development.md +0 -0
  39. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/google-sheets-setup.md +0 -0
  40. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/index.md +0 -0
  41. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/installation.md +0 -0
  42. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/operations.md +0 -0
  43. {dinary-1.2.1 → dinary-1.2.3}/docs/src/en/pwa-install.md +0 -0
  44. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/cloudflare-setup.md +0 -0
  45. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/deploy-oracle.md +0 -0
  46. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/deploy-selfhost.md +0 -0
  47. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/development.md +0 -0
  48. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/google-sheets-setup.md +0 -0
  49. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/index.md +0 -0
  50. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/installation.md +0 -0
  51. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/operations.md +0 -0
  52. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/pwa-install.md +0 -0
  53. {dinary-1.2.1 → dinary-1.2.3}/docs/src/ru/taxonomy.md +0 -0
  54. {dinary-1.2.1 → dinary-1.2.3}/invoke.yml +0 -0
  55. {dinary-1.2.1 → dinary-1.2.3}/pyproject.toml +0 -0
  56. {dinary-1.2.1 → dinary-1.2.3}/pytest.ini +0 -0
  57. {dinary-1.2.1 → dinary-1.2.3}/scripts/verup.sh +0 -0
  58. {dinary-1.2.1 → dinary-1.2.3}/specs/README.md +0 -0
  59. {dinary-1.2.1 → dinary-1.2.3}/specs/plans/analytics.md +0 -0
  60. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/architecture.md +0 -0
  61. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/catalog-api.md +0 -0
  62. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/classification-pipeline.md +0 -0
  63. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/currencies.md +0 -0
  64. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/frontend-cache.md +0 -0
  65. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/income-import.md +0 -0
  66. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/llm-providers.md +0 -0
  67. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/pwa-offline.md +0 -0
  68. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/receipt-fetching.md +0 -0
  69. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/sheets.md +0 -0
  70. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/sql-tool.md +0 -0
  71. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/stores.md +0 -0
  72. {dinary-1.2.1 → dinary-1.2.3}/specs/reference/timestamps.md +0 -0
  73. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/README.md +0 -0
  74. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/components.md +0 -0
  75. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/design-language.md +0 -0
  76. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/future-screens-guide.md +0 -0
  77. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/patterns.md +0 -0
  78. {dinary-1.2.1 → dinary-1.2.3}/specs/ui/screens.md +0 -0
  79. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/README.md +0 -0
  80. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/__init__.py +0 -0
  81. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/exchange_rates.py +0 -0
  82. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/llm_storage.py +0 -0
  83. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/llmbroker.py +0 -0
  84. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/nbp.py +0 -0
  85. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/nbs.py +0 -0
  86. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/rate_helpers.py +0 -0
  87. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/serbian_receipt_parser.py +0 -0
  88. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/adapters/sheets_client.py +0 -0
  89. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/catalog.py +0 -0
  90. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog.py +0 -0
  91. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer.py +0 -0
  92. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_categories.py +0 -0
  93. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_errors.py +0 -0
  94. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_events.py +0 -0
  95. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/catalog_writer_groups.py +0 -0
  96. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/expense_corrections.py +0 -0
  97. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/expenses.py +0 -0
  98. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/income.py +0 -0
  99. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/llm.py +0 -0
  100. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/qr_parser.py +0 -0
  101. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/controllers/rules.py +0 -0
  102. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/currencies.py +0 -0
  103. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/expense_corrections.py +0 -0
  104. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/expenses.py +0 -0
  105. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/income.py +0 -0
  106. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/llm.py +0 -0
  107. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/qr.py +0 -0
  108. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/receipts.py +0 -0
  109. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/api/rules.py +0 -0
  110. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/item_normalizer.py +0 -0
  111. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/persist.py +0 -0
  112. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/receipt_classifier.py +0 -0
  113. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/store_resolver.py +0 -0
  114. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/classification/task.py +0 -0
  115. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/rate_prefetch/task.py +0 -0
  116. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/income_sheet_logging.py +0 -0
  117. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/logging_jobs.py +0 -0
  118. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/sheet_logging.py +0 -0
  119. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/sheets_write.py +0 -0
  120. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/background/sheet_logging/task.py +0 -0
  121. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/config.py +0 -0
  122. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/catalog.py +0 -0
  123. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/classification_rules.py +0 -0
  124. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/currencies.py +0 -0
  125. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/db_migrations.py +0 -0
  126. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/expenses.py +0 -0
  127. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/income.py +0 -0
  128. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0001_initial_schema.rollback.sql +0 -0
  129. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0001_initial_schema.sql +0 -0
  130. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0002_exchange_rates_source_target.rollback.sql +0 -0
  131. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0002_exchange_rates_source_target.sql +0 -0
  132. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0003_app_currencies.rollback.sql +0 -0
  133. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0003_app_currencies.sql +0 -0
  134. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0004_receipt_pipeline.rollback.sql +0 -0
  135. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0004_receipt_pipeline.sql +0 -0
  136. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0005_income_logging.rollback.sql +0 -0
  137. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/0005_income_logging.sql +0 -0
  138. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/migrations/README.md +0 -0
  139. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/receipts.py +0 -0
  140. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/__init__.py +0 -0
  141. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_category_by_name.sql +0 -0
  142. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_existing_expense.sql +0 -0
  143. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/get_month_expenses.sql +0 -0
  144. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/insert_expense.sql +0 -0
  145. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/insert_income.sql +0 -0
  146. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/list_categories.sql +0 -0
  147. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/list_incomes.sql +0 -0
  148. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/logging_projection.sql +0 -0
  149. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/resolve_mapping_for_year.sql +0 -0
  150. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql/seed_load_categories.sql +0 -0
  151. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/sql_loader.py +0 -0
  152. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/db/storage.py +0 -0
  153. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/main.py +0 -0
  154. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/sheets/sheet_mapping.py +0 -0
  155. {dinary-1.2.1 → dinary-1.2.3}/src/dinary/sheets/sheets.py +0 -0
  156. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backup_retention.py +0 -0
  157. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backup_snapshots.py +0 -0
  158. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_replica.py +0 -0
  159. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_restore.py +0 -0
  160. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_status.py +0 -0
  161. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/backups_yandex.py +0 -0
  162. {dinary-1.2.1 → dinary-1.2.3}/tasks/backups/restore_utils.py +0 -0
  163. {dinary-1.2.1 → dinary-1.2.3}/tasks/db.py +0 -0
  164. {dinary-1.2.1 → dinary-1.2.3}/tasks/deploy.py +0 -0
  165. {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/build_docs.py +0 -0
  166. {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/constants.py +0 -0
  167. {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/dev.py +0 -0
  168. {dinary-1.2.1 → dinary-1.2.3}/tasks/devtools/env.py +0 -0
  169. {dinary-1.2.1 → dinary-1.2.3}/tasks/healthcheck.py +0 -0
  170. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/README.md +0 -0
  171. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/expense_import.py +0 -0
  172. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/report_2d_3d.py +0 -0
  173. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed.py +0 -0
  174. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed_config.py +0 -0
  175. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/seed_derivation.py +0 -0
  176. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/verify_equivalence.py +0 -0
  177. {dinary-1.2.1 → dinary-1.2.3}/tasks/imports/verify_income.py +0 -0
  178. {dinary-1.2.1 → dinary-1.2.3}/tasks/receipt.py +0 -0
  179. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/expenses.py +0 -0
  180. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/income.py +0 -0
  181. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/report_helpers.py +0 -0
  182. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/report_tasks.py +0 -0
  183. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/verify_budget.py +0 -0
  184. {dinary-1.2.1 → dinary-1.2.3}/tasks/reports/verify_income.py +0 -0
  185. {dinary-1.2.1 → dinary-1.2.3}/tasks/server.py +0 -0
  186. {dinary-1.2.1 → dinary-1.2.3}/tasks/setup.py +0 -0
  187. {dinary-1.2.1 → dinary-1.2.3}/tasks/sql.py +0 -0
  188. {dinary-1.2.1 → dinary-1.2.3}/tasks/ssh_utils.py +0 -0
  189. {dinary-1.2.1 → dinary-1.2.3}/tests/api/_admin_catalog_helpers.py +0 -0
  190. {dinary-1.2.1 → dinary-1.2.3}/tests/api/_api_helpers.py +0 -0
  191. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_add.py +0 -0
  192. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_delete.py +0 -0
  193. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_meta.py +0 -0
  194. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_catalog_patch.py +0 -0
  195. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_admin_llm.py +0 -0
  196. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api.py +0 -0
  197. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_catalog.py +0 -0
  198. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_concurrency.py +0 -0
  199. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_conflict.py +0 -0
  200. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_currencies.py +0 -0
  201. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_delete_expense.py +0 -0
  202. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_delete_receipt.py +0 -0
  203. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_expenses_recent_patch.py +0 -0
  204. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_get_expenses.py +0 -0
  205. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_income.py +0 -0
  206. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_post_expense.py +0 -0
  207. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_receipts.py +0 -0
  208. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_rules_approve.py +0 -0
  209. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_api_validation.py +0 -0
  210. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_receipt_pipeline_e2e.py +0 -0
  211. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_receipt_review.py +0 -0
  212. {dinary-1.2.1 → dinary-1.2.3}/tests/api/test_review_page_ux.py +0 -0
  213. {dinary-1.2.1 → dinary-1.2.3}/tests/conftest.py +0 -0
  214. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/_currency_rates_helpers.py +0 -0
  215. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_misc.py +0 -0
  216. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_nbp.py +0 -0
  217. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_currency_rates_resolve.py +0 -0
  218. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_rate_helpers.py +0 -0
  219. {dinary-1.2.1 → dinary-1.2.3}/tests/currency/test_rate_prefetch_task.py +0 -0
  220. {dinary-1.2.1 → dinary-1.2.3}/tests/imports/test_expense_import.py +0 -0
  221. {dinary-1.2.1 → dinary-1.2.3}/tests/imports/test_seed_config.py +0 -0
  222. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/_catalog_writer_helpers.py +0 -0
  223. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/_ledger_repo_helpers.py +0 -0
  224. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_catalog_writer_invariants.py +0 -0
  225. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_catalog_writer_patch.py +0 -0
  226. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_income_db.py +0 -0
  227. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_catalog.py +0 -0
  228. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_insert.py +0 -0
  229. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_lookup.py +0 -0
  230. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_expenses_race.py +0 -0
  231. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_jobs.py +0 -0
  232. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_ledger_repo_logging_projection.py +0 -0
  233. {dinary-1.2.1 → dinary-1.2.3}/tests/ledger/test_migrations.py +0 -0
  234. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/_report_2d_3d_helpers.py +0 -0
  235. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d.py +0 -0
  236. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_aggregate.py +0 -0
  237. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_render.py +0 -0
  238. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_report_2d_3d_resolve.py +0 -0
  239. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_expenses.py +0 -0
  240. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_income.py +0 -0
  241. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_verify_budget.py +0 -0
  242. {dinary-1.2.1 → dinary-1.2.3}/tests/reports/test_reports_verify_income.py +0 -0
  243. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_classification_rules.py +0 -0
  244. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_item_normalizer.py +0 -0
  245. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_llm_storage.py +0 -0
  246. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_llmbroker.py +0 -0
  247. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_qr_parser.py +0 -0
  248. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_classification.py +0 -0
  249. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_classifier.py +0 -0
  250. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_receipt_parser.py +0 -0
  251. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_sql_loader.py +0 -0
  252. {dinary-1.2.1 → dinary-1.2.3}/tests/services/test_store_resolver.py +0 -0
  253. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheet_logging_helpers.py +0 -0
  254. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheet_mapping_helpers.py +0 -0
  255. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/_sheets_helpers.py +0 -0
  256. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_income_drain.py +0 -0
  257. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging.py +0 -0
  258. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_derive.py +0 -0
  259. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_drain.py +0 -0
  260. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_logging_drain_one.py +0 -0
  261. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_parse.py +0 -0
  262. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_reload.py +0 -0
  263. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheet_mapping_resolve.py +0 -0
  264. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheets_read.py +0 -0
  265. {dinary-1.2.1 → dinary-1.2.3}/tests/sheets/test_sheets_rows.py +0 -0
  266. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_receipt_drain.py +0 -0
  267. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_receipt_pipeline.py +0 -0
  268. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_reclassify_receipts.py +0 -0
  269. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_restore.py +0 -0
  270. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_retention.py +0 -0
  271. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_status.py +0 -0
  272. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_backups_yandex_setup.py +0 -0
  273. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_db.py +0 -0
  274. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_deploy.py +0 -0
  275. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_dev.py +0 -0
  276. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_imports.py +0 -0
  277. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_reports.py +0 -0
  278. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_restore_utils.py +0 -0
  279. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_server.py +0 -0
  280. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_server_receipt.py +0 -0
  281. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_setup_replica.py +0 -0
  282. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_ssh_utils.py +0 -0
  283. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tasks_ssh_utils_scripts.py +0 -0
  284. {dinary-1.2.1 → dinary-1.2.3}/tests/tasks/test_tools_sql.py +0 -0
  285. {dinary-1.2.1 → dinary-1.2.3}/tests/test_config.py +0 -0
  286. {dinary-1.2.1 → dinary-1.2.3}/tests/test_main.py +0 -0
  287. {dinary-1.2.1 → dinary-1.2.3}/tests/test_webapp_api_contract.py +0 -0
  288. {dinary-1.2.1 → dinary-1.2.3}/uv.lock +0 -0
  289. {dinary-1.2.1 → dinary-1.2.3}/webapp/index.html +0 -0
  290. {dinary-1.2.1 → dinary-1.2.3}/webapp/package-lock.json +0 -0
  291. {dinary-1.2.1 → dinary-1.2.3}/webapp/package.json +0 -0
  292. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/apple-touch-icon-precomposed.png +0 -0
  293. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/apple-touch-icon.png +0 -0
  294. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/favicon.ico +0 -0
  295. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-180.png +0 -0
  296. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-192.png +0 -0
  297. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/icons/icon-512.png +0 -0
  298. {dinary-1.2.1 → dinary-1.2.3}/webapp/public/manifest.json +0 -0
  299. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/App.vue +0 -0
  300. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/_request.js +0 -0
  301. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/adminLlm.js +0 -0
  302. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/catalog.js +0 -0
  303. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/currencies.js +0 -0
  304. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/expenseCorrections.js +0 -0
  305. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/expenses.js +0 -0
  306. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/income.js +0 -0
  307. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/receipts.js +0 -0
  308. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/api/review.js +0 -0
  309. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/assets/base.css +0 -0
  310. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/BaseModal.vue +0 -0
  311. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/BaseSheet.vue +0 -0
  312. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CatalogSelectField.vue +0 -0
  313. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CategoryQuickPicks.vue +0 -0
  314. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CategorySheet.vue +0 -0
  315. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ConfirmDeleteSheet.vue +0 -0
  316. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CorrectionSheet.vue +0 -0
  317. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CurrencyAmountRow.vue +0 -0
  318. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/CurrencyPicker.vue +0 -0
  319. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseEditSheet.vue +0 -0
  320. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseForm.vue +0 -0
  321. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ExpenseRow.vue +0 -0
  322. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/HeaderSegmented.vue +0 -0
  323. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/HealthSummaryCard.vue +0 -0
  324. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IconBtn.vue +0 -0
  325. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeEditSheet.vue +0 -0
  326. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeForm.vue +0 -0
  327. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/IncomeRow.vue +0 -0
  328. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/InlineCreateEvent.vue +0 -0
  329. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/InlineCreateRow.vue +0 -0
  330. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/KeyboardSaveBar.vue +0 -0
  331. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ManageList.vue +0 -0
  332. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/QrScanner.vue +0 -0
  333. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/QueueModal.vue +0 -0
  334. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ReceiptCascadeCard.vue +0 -0
  335. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/RuleRow.vue +0 -0
  336. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/ScopeSelector.vue +0 -0
  337. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/StatusDot.vue +0 -0
  338. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/components/TagPicker.vue +0 -0
  339. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/addResult.js +0 -0
  340. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/catalogManage.js +0 -0
  341. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/flushQueue.js +0 -0
  342. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/flushReceiptQueue.js +0 -0
  343. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/receipt.js +0 -0
  344. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useExpenseDeleteFlow.js +0 -0
  345. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useKeyboardVisible.js +0 -0
  346. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useOnline.js +0 -0
  347. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useStaleCache.js +0 -0
  348. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/useSwipeRow.js +0 -0
  349. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/composables/zbar.js +0 -0
  350. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/legacy-pwa-cleanup.js +0 -0
  351. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/main.js +0 -0
  352. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/modals/EditModal.vue +0 -0
  353. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/catalog.js +0 -0
  354. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/currency.js +0 -0
  355. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/frequentCategories.js +0 -0
  356. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/income.js +0 -0
  357. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/llm.js +0 -0
  358. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/queue.js +0 -0
  359. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/receiptQueue.js +0 -0
  360. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/review.js +0 -0
  361. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/stores/toast.js +0 -0
  362. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/AddView.vue +0 -0
  363. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/IncomeView.vue +0 -0
  364. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/LLMView.vue +0 -0
  365. {dinary-1.2.1 → dinary-1.2.3}/webapp/src/views/ReviewView.vue +0 -0
  366. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/BaseSheet.test.js +0 -0
  367. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CategoryQuickPicks.test.js +0 -0
  368. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CategorySheet.test.js +0 -0
  369. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/CurrencyAmountRow.test.js +0 -0
  370. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ExpenseEditSheet.test.js +0 -0
  371. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ExpenseRow.test.js +0 -0
  372. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ReceiptCascadeCard.test.js +0 -0
  373. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/ScopeSelector.test.js +0 -0
  374. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-adminLlm.test.js +0 -0
  375. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-catalog.test.js +0 -0
  376. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-currencies.test.js +0 -0
  377. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-expenses.test.js +0 -0
  378. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-income.test.js +0 -0
  379. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-receipts.test.js +0 -0
  380. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-request.test.js +0 -0
  381. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/api-review.test.js +0 -0
  382. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-app.test.js +0 -0
  383. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-catalog-select-field.test.js +0 -0
  384. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-correction-sheet.test.js +0 -0
  385. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-currency-picker.test.js +0 -0
  386. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-edit-modal.test.js +0 -0
  387. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-expense-edit-sheet.test.js +0 -0
  388. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-expense-form.test.js +0 -0
  389. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-header-segmented.test.js +0 -0
  390. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-health-summary-card.test.js +0 -0
  391. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-inline-create-event.test.js +0 -0
  392. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-inline-create-row.test.js +0 -0
  393. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-manage-list.test.js +0 -0
  394. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-queue-modal.test.js +0 -0
  395. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-review-view.test.js +0 -0
  396. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-rule-row.test.js +0 -0
  397. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-status-dot.test.js +0 -0
  398. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/component-tag-picker.test.js +0 -0
  399. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-add-result.test.js +0 -0
  400. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-catalog-manage.test.js +0 -0
  401. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-flush-queue.test.js +0 -0
  402. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-flush-receipt-queue.test.js +0 -0
  403. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-keyboard-visible.test.js +0 -0
  404. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-receipt.test.js +0 -0
  405. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-stale-cache.test.js +0 -0
  406. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/composable-use-online.test.js +0 -0
  407. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/frequentCategories.test.js +0 -0
  408. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/legacy-pwa-cleanup.test.js +0 -0
  409. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/setup.js +0 -0
  410. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-catalog.test.js +0 -0
  411. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-currency.test.js +0 -0
  412. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-income.test.js +0 -0
  413. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-llm.test.js +0 -0
  414. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-queue.test.js +0 -0
  415. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-receipt-queue-durability.test.js +0 -0
  416. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-receipt-queue.test.js +0 -0
  417. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-review.test.js +0 -0
  418. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/store-toast.test.js +0 -0
  419. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/useExpenseDeleteFlow.test.js +0 -0
  420. {dinary-1.2.1 → dinary-1.2.3}/webapp/tests/useSwipeRow.test.js +0 -0
  421. {dinary-1.2.1 → dinary-1.2.3}/webapp/vite.config.js +0 -0
  422. {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.1
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 income_original_export
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="export-income-original")
254
- def export_income_original(c, output=""):
255
- """Export income from all sheets in original currency (RUB/RSD) to a JSON file.
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 a flat list of {year, month, amount, currency} entries to JSON.
259
- Default output path: data/income_original.json. Override with --output.
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 income_original_export.DEFAULT_OUTPUT
262
- count = income_original_export.export_to_file(dest)
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="provider.last_error_detail" class="error-detail">{{ provider.last_error_detail }}</div>
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 {