Skip to content

feat(tickets_v2): add per-product VAT rate and receipt VAT breakdown#967

Draft
japsu wants to merge 22 commits into
mainfrom
feat/tickets-v2-vat
Draft

feat(tickets_v2): add per-product VAT rate and receipt VAT breakdown#967
japsu wants to merge 22 commits into
mainfrom
feat/tickets-v2-vat

Conversation

@japsu
Copy link
Copy Markdown
Contributor

@japsu japsu commented Apr 24, 2026

Summary

  • Adds vat_percentage field to Product (Finnish rates: 0%, 10%, 13.5%, 25.5%); prices remain VAT-inclusive
  • Receipt emails now show a dynamic VAT breakdown per rate instead of the hardcoded (VAT 0%) placeholder
  • Shop product card shows the applicable VAT rate next to the price
  • Order summary table (ProductsTable) includes per-rate VAT breakdown rows in the footer
  • Admin product form exposes vatPercentage as a SingleSelect field; changing the rate triggers a new product revision (same as price)

Test plan

  • Run migration: DEBUG=True uv run python manage.py migrate
  • Create a product with a non-zero VAT rate via the admin product form — confirm the field appears and saves correctly
  • Change the VAT rate on an already-sold product — confirm a new revision is created
  • Place an order for products with mixed VAT rates — confirm the receipt email shows correct per-rate VAT breakdown
  • Confirm ProductCard shows "incl. X% VAT" next to the price in the shop
  • Confirm the order summary page shows a VAT breakdown in the table footer
  • Backend tests: docker compose -f docker-compose.test.yml run --rm test
  • Frontend lint/type-check: npm run test (from kompassi-v2-frontend/)

🤖 Generated with Claude Code

Comment thread kompassi-v2-frontend/src/helpers/formatVatRate.ts
@Aketzu
Copy link
Copy Markdown
Contributor

Aketzu commented Apr 24, 2026

Some early smokes... Found and fixed issues with Claude Opus in feat/tickets-v2-vat-fixes

  • New product form doesn't show VAT percentages
  • Saving new product doesn't work because vatPercentage is not snake_cased
  • Paytrail requires vatPercentage to not be a string
  • Order complete shows "NaN% VAT" (snake_camel mixup)

Todo:

  • Email shows "VAT 1E+1%"
  • Receipt must show seller's name, contact info, VAT number, order date

japsu and others added 10 commits May 12, 2026 23:14
Products now have a vat_percentage field (Finnish rates: 0%, 10%,
13.5%, 25.5%). Prices remain VAT-inclusive. Receipt emails show a
dynamic VAT breakdown instead of the hardcoded "(VAT 0%)" placeholder.
The shop product card shows the applicable rate, and the order summary
table includes a per-rate VAT breakdown in the footer. The admin product
form exposes vatPercentage as a SingleSelect field; changing the rate
triggers a new product revision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PaytrailItem model and populates the items array in Paytrail payment
requests, enabling per-rate VAT breakdown in Paytrail merchant reports.
Each order line becomes one item with unitPrice, units, vatPercentage,
and a description (product title). For new orders, the order is fetched
back from the DB inside the same transaction to retrieve product details.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add format_vat_rate helper to strip trailing zeros from VAT rate
  display (25.50 → 25.5, 10.00 → 10). Applied in email templates
  via template filter and in frontend via parseFloat in translation
  functions.
- Assert Paytrail item sum equals payment amount via model_validator
  on CreatePaymentRequest, catching mismatches at construction time.
- Raise UnsaneSituation instead of silently passing empty items when
  fetched_order is None after creation in app.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
format_vat_rate now accepts a language parameter and uses comma as
decimal separator for Finnish and Swedish locales (25,5%) while
keeping period for English (25.5%). Email templates pass the locale
explicitly via filter argument (eg. format_vat_rate:"fi").

On the frontend, a new formatVatRate helper follows the same pattern
as formatMoney. ProductCard and ProductsTable accept a locale prop
and format the rate before passing it to the vatIncluded translation
function, which now receives a pre-formatted string.

Also adds missing vatPercentage to GraphQL queries in profile order
detail and admin order detail pages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vatIncluded is a function that cannot be serialized across the
server/client boundary. Moving it to serverAttributes prevents the
'Functions cannot be passed directly to Client Components' error
on the /products page.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CreateProductForm backend requires vat_percentage but the frontend
new product modal was missing this field, causing a validation error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CreateProduct mutation was passing form_data directly to the Django
form without converting camelCase keys (e.g. vatPercentage) to
snake_case (vat_percentage), causing validation to fail. The
UpdateProduct mutation already did this conversion.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Paytrail API requires vatPercentage to be a number, but Pydantic v2
serializes Decimal as a string in JSON mode. Changing the type to float
ensures correct JSON serialization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The order API serializes with by_alias=True, but OrderProduct was
missing the vatPercentage alias. The frontend received vat_percentage
(snake_case) which didn't match its vatPercentage interface, causing
NaN% VAT display and duplicate React keys.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@japsu japsu force-pushed the feat/tickets-v2-vat branch from 3a12458 to e00b013 Compare May 12, 2026 20:14
japsu and others added 12 commits May 12, 2026 23:19
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rows are months, columns are VAT percentages found in paid orders plus
a total column. Only paid orders (status=3) are included; refunds are
noted in the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
VAT amount = gross * rate / (100 + rate) since prices are VAT-inclusive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Pull title, column title, and footer dicts into module-level constants
  so the empty-result and non-empty paths share the same wording.
- Use the shared format_vat_rate helper for column titles (with Swedish
  added) instead of duplicating the formatting logic.
- Quantize values to cents in Decimal space before converting to float so
  rounding is deterministic and the SUM total row matches the per-cell sums.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move the per-rate gross/vat/net summation into VatBreakdownLine.from_order_products
so the formula lives next to the model and OrderMixin.vat_breakdown is a one-liner.
Also hoists the local defaultdict import to module scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Decimal('10.00').normalize() returns Decimal('1E+1'), so the previous
str(rate.normalize()) produced '1E+1' for 10% VAT and '1E+2' for 100%,
breaking receipt emails (used as a Django template filter) and the
admin order detail VAT rows.

Use {rate:f} formatting and strip trailing zeros from the string instead.
The asserted doctest output now actually holds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both NullProvider and PaytrailProvider short-circuit zero-price orders
before reading order_products, so the extra Order.get round trip after
order.save() is wasted work for those orders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit:
- format_vat_rate parametrized over the edge cases that broke before the
  scientific-notation fix (10.00 -> '10', not '1E+1')
- VatBreakdownLine.from_order_products across empty, multi-rate, and
  zero-rate inputs

Integration (pytest-django):
- VatByMonth.report on an empty event
- A full run with multiple months, multiple VAT rates, an unpaid order
  (excluded by status filter) and a zero-VAT paid order (excluded by the
  vat_percentage > 0 filter)
- UTC-vs-Europe/Helsinki month boundary check
- Localized VAT column titles

Also fix Report.total_row showing '' instead of 'Total' in the first
column: the month column was using total_by=TotalBy.NONE which suppresses
the label; default total_by=SUM is the right choice for a STRING
header column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants