Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c6fa58c
feat(tickets_v2): add per-product VAT rate and receipt VAT breakdown
japsu Apr 24, 2026
721b341
feat(tickets_v2): send items in Paytrail CreatePaymentRequest
japsu Apr 24, 2026
14d8318
fix(tickets_v2): review fixes for VAT branch
japsu Apr 24, 2026
61353c3
fix(tickets_v2): locale-aware VAT rate formatting
japsu Apr 24, 2026
a4842b9
chore: generate frontend types
japsu Apr 24, 2026
0efe556
fix: move vatIncluded from clientAttributes to serverAttributes
Aketzu Apr 24, 2026
a416075
fix: add VAT percentage field to new product form
Aketzu Apr 24, 2026
b3489da
fix: apply camel_case_keys_to_snake_case in CreateProduct mutation
Aketzu Apr 24, 2026
bf026b6
fix: serialize PaytrailItem vatPercentage as JSON number
Aketzu Apr 24, 2026
81b25b9
fix: add serialization alias for OrderProduct.vat_percentage
Aketzu Apr 24, 2026
f154f81
fix: make vatPercentage required on add product form
japsu May 12, 2026
548e501
fix: add vatPercentage and locale to ProductCard in new order page
japsu May 12, 2026
3eca201
chore: translation style
japsu May 12, 2026
6e9e01a
fix(tickets_v2): typecheck
japsu May 12, 2026
67625a5
feat: add VAT by month report to tickets_v2 reports
japsu May 12, 2026
69cf838
fix: report VAT tax amount instead of gross revenue
japsu May 12, 2026
44eb7a1
fix: omit 0% VAT rate from VAT by month report
japsu May 12, 2026
1443f7a
refactor(tickets_v2): tidy VAT by month report
japsu May 12, 2026
8414fc3
refactor(tickets_v2): centralize VAT breakdown calculation
japsu May 12, 2026
58d6de6
fix(tickets_v2): avoid scientific notation in format_vat_rate
japsu May 12, 2026
a04b808
perf(tickets_v2): skip Order.get for zero-price orders in create_order
japsu May 12, 2026
ef59cb5
test(tickets_v2): add VAT branch unit and integration tests
japsu May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions kompassi-v2-frontend/src/__generated__/gql.ts

Large diffs are not rendered by default.

33 changes: 19 additions & 14 deletions kompassi-v2-frontend/src/__generated__/graphql.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ const query = graphql(`
title
quantity
price
vatPercentage
}
paymentStamps {
...AdminOrderPaymentStamp
Expand Down Expand Up @@ -542,6 +543,7 @@ export default async function AdminOrderPage(props: Props) {
<AccordionBody>
<ProductsTable
order={order}
locale={locale}
messages={translations.Tickets}
compact
className="m-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ graphql(`
title
description
price
vatPercentage
isAvailable
availableFrom
availableUntil
Expand Down Expand Up @@ -172,7 +173,12 @@ export default async function OrdersPage(props: Props) {
onSubmit={adminCreateOrder.bind(null, locale, eventSlug)}
>
{products.map((product) => (
<ProductCard key={product.id} product={product} messages={producT}>
<ProductCard
key={product.id}
product={product}
locale={locale}
messages={producT}
>
<div className="form-text">
<span className="me-3">
{producT.clientAttributes.countReserved.title}:{" "}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default async function OrderPage(props: Props) {
<ViewContainer>
<OrderHeader order={order} messages={t} locale={locale} event={event} />

<ProductsTable order={order} messages={t} />
<ProductsTable order={order} locale={locale} messages={t} />

{showPayButton && (
<Section>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import SubmitButton from "@/components/forms/SubmitButton";
import ModalButton from "@/components/ModalButton";
import TicketsAdminView from "@/components/tickets/TicketsAdminView";
import formatMoney from "@/helpers/formatMoney";
import formatVatRate from "@/helpers/formatVatRate";
import getPageTitle from "@/helpers/getPageTitle";
import { getTranslations } from "@/translations";

Expand All @@ -29,6 +30,7 @@ graphql(`
title
description
price
vatPercentage
eticketsPerProduct
maxPerOrder
}
Expand All @@ -41,6 +43,7 @@ graphql(`
title
description
price
vatPercentage
eticketsPerProduct
maxPerOrder
availableFrom
Expand Down Expand Up @@ -183,6 +186,17 @@ export default async function AdminProductDetailPage(props: Props) {
decimalPlaces: 2,
...t.clientAttributes.unitPrice,
},
{
slug: "vatPercentage",
type: "SingleSelect",
choices: [
{ slug: "0.00", title: "0%" },
{ slug: "10.00", title: "10%" },
{ slug: "13.50", title: "13.5%" },
{ slug: "25.50", title: "25.5%" },
],
...t.clientAttributes.vatPercentage,
},
{
slug: "eticketsPerProduct",
type: "NumberField",
Expand Down Expand Up @@ -294,6 +308,15 @@ export default async function AdminProductDetailPage(props: Props) {
getCellContents: (product) => formatMoney(product.price),
className: "col-1 align-middle",
},
{
slug: "vatPercentage",
title: t.clientAttributes.vatPercentage.title,
getCellContents: (product) =>
t.serverAttributes.vatIncluded(
formatVatRate(product.vatPercentage, locale),
),
className: "col-1 align-middle",
},
];

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ export default async function ProductsPage(props: Props) {
decimalPlaces: 2,
...t.clientAttributes.unitPrice,
},
{
slug: "vatPercentage",
type: "SingleSelect",
required: true,
choices: [
{ slug: "0.00", title: "0%" },
{ slug: "10.00", title: "10%" },
{ slug: "13.50", title: "13.5%" },
{ slug: "25.50", title: "25.5%" },
],
...t.clientAttributes.vatPercentage,
},
{
slug: "quota",
type: "NumberField",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default async function TicketsPage(props: Props) {
<ProductCard
key={product.id}
product={product}
locale={locale}
messages={producT}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const query = graphql(`
title
quantity
price
vatPercentage
}

event {
Expand Down Expand Up @@ -122,7 +123,11 @@ export default async function ProfileOrderPage(props: Props) {
event={order.event}
/>

<ProductsTable order={order} messages={translations.Tickets} />
<ProductsTable
order={order}
locale={locale}
messages={translations.Tickets}
/>

{order.canPay && (
<form action={payOrder.bind(null, locale, eventSlug, orderId)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ Current approach is as follows:

3. Download the SVG
4. Using eg. OpenInNewTab.tsx as a template, make the SVG into a React component

- You may need to tweak the vertical translate to make the icon align with text.

The `.material-symbol` class lives in `globals.scss`.
Expand Down
14 changes: 13 additions & 1 deletion kompassi-v2-frontend/src/components/tickets/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import Card from "react-bootstrap/Card";
import CardBody from "react-bootstrap/CardBody";
import CardTitle from "react-bootstrap/CardTitle";
import formatMoney from "@/helpers/formatMoney";
import formatVatRate from "@/helpers/formatVatRate";
import { Product } from "@/services/tickets";
import { Translations } from "@/translations/en";

interface Props {
product: Product;
locale: string;
messages: Translations["Tickets"]["Product"];
children?: ReactNode;
}

export default function ProductCard({ product, messages: t, children }: Props) {
export default function ProductCard({
product,
locale,
messages: t,
children,
}: Props) {
const className = product.available ? "" : "text-muted";
return (
<Card key={product.id} className="mb-3">
Expand All @@ -25,6 +32,11 @@ export default function ProductCard({ product, messages: t, children }: Props) {

<div className={`col-md m-md-0 mb-3 fs-4 text-md-end`}>
{formatMoney(product.price)}
<div className="text-muted fs-6">
{t.serverAttributes.vatIncluded(
formatVatRate(product.vatPercentage, locale),
)}
</div>
</div>

<div className={`col-md fs-4`}>
Expand Down
35 changes: 35 additions & 0 deletions kompassi-v2-frontend/src/components/tickets/ProductsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Column, DataTable } from "../DataTable";
import formatMoney from "@/helpers/formatMoney";
import formatVatRate from "@/helpers/formatVatRate";
import type { Translations } from "@/translations/en";

interface Product {
title: string;
quantity: number;
price: string;
vatPercentage: string;
}

interface Order {
Expand All @@ -15,13 +17,33 @@ interface Order {

interface Props {
order: Order;
locale: string;
messages: Translations["Tickets"];
className?: string;
compact?: boolean;
}

function computeVatBreakdown(
products: Product[],
): { rate: string; vat: string }[] {
const totals = new Map<string, number>();
for (const p of products) {
const gross = parseFloat(p.price) * p.quantity;
const prev = totals.get(p.vatPercentage) ?? 0;
totals.set(p.vatPercentage, prev + gross);
}
return Array.from(totals.entries())
.sort(([a], [b]) => parseFloat(a) - parseFloat(b))
.map(([rate, gross]) => {
const r = parseFloat(rate);
const vat = (gross * r) / (100 + r);
return { rate, vat: vat.toFixed(2) };
});
}

export default function ProductsTable({
order,
locale,
messages: t,
className,
compact,
Expand Down Expand Up @@ -63,6 +85,8 @@ export default function ProductsTable({

className = "table table-striped " + (className ?? "mb-5");

const vatBreakdown = computeVatBreakdown(order.products);

return (
<DataTable className={className} rows={order.products} columns={columns}>
<tfoot>
Expand All @@ -75,6 +99,17 @@ export default function ProductsTable({
<strong>{formatMoney(order.totalPrice)}</strong>
</td>
</tr>
{vatBreakdown.map(({ rate, vat }) => (
<tr key={rate} className="text-muted">
<td className="col-8 small">
{t.Product.serverAttributes.vatIncluded(
formatVatRate(rate, locale),
)}
</td>
<td className="col text-end"></td>
<td className="col text-end small">{formatMoney(vat)}</td>
</tr>
))}
</tfoot>
</DataTable>
);
Expand Down
8 changes: 8 additions & 0 deletions kompassi-v2-frontend/src/helpers/formatVatRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function formatVatRate(value: string, locale: string = "en") {
const num = parseFloat(value);
const formatted = num.toString();
if (locale === "fi" || locale === "sv") {
return formatted.replace(".", ",");
}
Comment thread
japsu marked this conversation as resolved.
return formatted;
}
2 changes: 2 additions & 0 deletions kompassi-v2-frontend/src/services/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface Product {
title: string;
description: string;
price: string;
vatPercentage: string;
maxPerOrder: number;
available?: boolean;
}
Expand Down Expand Up @@ -169,6 +170,7 @@ export interface Order {
title: string;
price: string;
quantity: number;
vatPercentage: string;
}[];
}

Expand Down
8 changes: 7 additions & 1 deletion kompassi-v2-frontend/src/translations/en.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { JSX, ReactNode } from "react";

const translations = {
Common: {
ok: "OK",
Expand Down Expand Up @@ -531,6 +530,12 @@ const translations = {
selectedQuotas: "Selected quotas",
soldOut: "Sold out",
isAvailable: "Availability schedule",
vatPercentage: {
title: "VAT rate",
helpText:
"The VAT rate that applies to this product. Prices are VAT-inclusive.",
},
vatBreakdown: "VAT breakdown",
dragToReorder: "Drag to reorder",
newProductQuota: {
title: "Quota",
Expand All @@ -539,6 +544,7 @@ const translations = {
},
},
serverAttributes: {
vatIncluded: (rate: string) => `incl. VAT ${rate}%`,
isAvailable: {
untilFurtherNotice: "Available until further notice",
untilTime: (formattedTime: string) =>
Expand Down
7 changes: 7 additions & 0 deletions kompassi-v2-frontend/src/translations/fi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,12 @@ const translations: Translations = {
selectedQuotas: "Valitut kiintiöt",
soldOut: "Loppuunmyyty",
isAvailable: "Saatavuusaika",
vatPercentage: {
title: "ALV-prosentti",
helpText:
"Tähän tuotteeseen sovellettava arvonlisäveroprosentti. Hinnat sisältävät ALV:n.",
},
vatBreakdown: "ALV-erittely",
dragToReorder: "Vedä ja pudota järjestääksesi tuotteita",
newProductQuota: {
title: "Kiintiö",
Expand All @@ -534,6 +540,7 @@ const translations: Translations = {
},
},
serverAttributes: {
vatIncluded: (rate: string) => `sis. ALV ${rate}%`,
isAvailable: {
untilFurtherNotice: "Saatavilla toistaiseksi",
untilTime: (formattedTime: string) =>
Expand Down
7 changes: 7 additions & 0 deletions kompassi-v2-frontend/src/translations/sv.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
// Translators: Kirsi Västi, Calle Tengman, Luka Pajukanta, Claude Sonnet 4.6

import { ReactNode, JSX } from "react";
import en, { Translations } from "./en";

Check warning on line 4 in kompassi-v2-frontend/src/translations/sv.tsx

View workflow job for this annotation

GitHub Actions / eslint

'en' is defined but never used

/// Mark untranslated English strings with this
/// Eg.
/// { foo: UNTRANSLATED("bar") }
function UNTRANSLATED<T>(wat: T): T {

Check warning on line 9 in kompassi-v2-frontend/src/translations/sv.tsx

View workflow job for this annotation

GitHub Actions / eslint

'UNTRANSLATED' is defined but never used
return wat;
}

/// Mark strings to be checked by a native speaker / more experienced translator with this
function UNSURE<T>(wat: T): T {

Check warning on line 14 in kompassi-v2-frontend/src/translations/sv.tsx

View workflow job for this annotation

GitHub Actions / eslint

'UNSURE' is defined but never used
return wat;
}

Expand Down Expand Up @@ -518,6 +518,12 @@
selectedQuotas: "Valda kvoter",
soldOut: "Slutsåld",
isAvailable: "Tillgänglighetsschema",
vatPercentage: {
title: "Momssats",
helpText:
"Den momssats som gäller för denna produkt. Priserna inkluderar moms.",
},
vatBreakdown: "Momsspecifikation",
dragToReorder: "Dra för att sortera om",
newProductQuota: {
title: "Kvot",
Expand All @@ -526,6 +532,7 @@
},
},
serverAttributes: {
vatIncluded: (rate: string) => `inkl. moms${rate}%`,
isAvailable: {
untilFurtherNotice: "Tillgänglig tills vidare",
untilTime: (formattedTime: string) =>
Expand Down
4 changes: 3 additions & 1 deletion kompassi/tickets_v2/graphql/mutations/create_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from kompassi.access.cbac import graphql_check_model
from kompassi.core.models import Event
from kompassi.core.utils.form_utils import camel_case_keys_to_snake_case
from kompassi.event_log_v2.utils.emit import emit

from ...models.product import Product
Expand All @@ -28,6 +29,7 @@ class Meta:
"title",
"description",
"price",
"vat_percentage",
]


Expand All @@ -48,7 +50,7 @@ def mutate(
event = Event.objects.get(slug=input.event_slug)
graphql_check_model(Product, event.scope, info, operation="create")

form = CreateProductForm(data=input.form_data) # type: ignore
form = CreateProductForm(data=camel_case_keys_to_snake_case(input.form_data)) # type: ignore
if not form.is_valid():
raise ValueError(form.errors)

Expand Down
1 change: 1 addition & 0 deletions kompassi/tickets_v2/graphql/mutations/update_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class Meta:
"title",
"description",
"price",
"vat_percentage",
"max_per_order",
"etickets_per_product",
"available_from",
Expand Down
Loading
Loading