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