Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, jest, test } from '@jest/globals'
import PDFDocument from 'pdfkit'
import { generateInvoicePdf, type InvoiceData } from '../invoice-generator.ts'

// `import.meta.url` in the font loader cannot be parsed by Jest's CJS transform.
jest.mock('../museo-font.ts', () => ({ tryLoadMuseoFont: () => null }))

const INVOICE: InvoiceData = {
invoiceNumber: 'PB-CON26-001',
invoiceDate: '20.10.2026',
purchaserName: 'Mika Muster',
purchaserEmail: 'mika@example.com',
companyName: 'Beispiel GmbH',
companyVatId: null,
billingAddressLine1: 'Musterstraße 1',
billingAddressLine2: null,
billingCity: 'Wien',
billingPostalCode: '1010',
billingCountry: null,
conferenceTitle: 'Konferenz 2026',
ticketType: 'Regular',
ticketCount: 1,
unitPriceGrossCents: 11900,
subtotalCents: 10000,
discountAmountCents: 0,
vatAmountCents: 1900,
totalGrossCents: 11900,
}

/** Render the invoice and return every string drawn into it. */
async function renderedTexts(billingCountry: string | null) {
const text = jest.spyOn(PDFDocument.prototype, 'text')
try {
await generateInvoicePdf({ ...INVOICE, billingCountry })
return text.mock.calls.map(([value]) => value)
} finally {
text.mockRestore()
}
}

describe('invoice billing country', () => {
test('prints an ISO code as the German country name', async () => {
const texts = await renderedTexts('AT')

expect(texts).toContain('Österreich')
expect(texts).not.toContain('AT')
})

test('prints legacy free text exactly as it was entered', async () => {
expect(await renderedTexts('Germany')).toContain('Germany')
expect(await renderedTexts('Deutschland')).toContain('Deutschland')
})

test('prints no country line when the country is missing or blank', async () => {
const withCountry = await renderedTexts('AT')

for (const missing of [null, '', ' ']) {
const texts = await renderedTexts(missing)
expect(texts).toEqual(withCountry.filter((value) => value !== 'Österreich'))
}
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import PDFDocument from 'pdfkit'
import { formatBillingCountry } from '../../../../../shared-code/index.ts'
import { tryLoadMuseoFont } from './museo-font.js'

// Seller info (hardcoded)
Expand Down Expand Up @@ -301,8 +302,9 @@ export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
doc.text(`${data.billingPostalCode || ''} ${data.billingCity || ''}`.trim(), 50, y)
y += 12
}
if (data.billingCountry) {
doc.text(data.billingCountry, 50, y)
const billingCountry = formatBillingCountry(data.billingCountry)
if (billingCountry) {
doc.text(billingCountry, 50, y)
y += 12
}
if (data.companyVatId) {
Expand Down
2 changes: 1 addition & 1 deletion docs/tickets.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ One record per purchase.
| `billing_address_line2` | string | |
| `billing_city` | string | |
| `billing_postal_code` | string | |
| `billing_country` | string | |
| `billing_country` | string | ISO 3166-1 alpha-2 code (e.g. `DE`), see `shared-code/helpers/billingCountry.ts`. Older orders hold free text; the invoice prints those as entered |
| `ticket_type` | enum | `early_bird`, `regular`, `discounted` |
| `ticket_count` | integer | |
| `unit_price_cents` | integer | Per-ticket price in cents |
Expand Down
26 changes: 26 additions & 0 deletions nuxt-app/components/tickets/TicketCountrySelect.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { BILLING_COUNTRIES } from 'shared-code'

// Billing country as an ISO 3166-1 alpha-2 code (e.g. "DE"), as stored on the order.
defineProps<{ modelValue: string; required?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>

<template>
<div>
<label class="text-gray-400 mb-2 block text-sm font-bold">
Land <span v-if="required" class="text-lime">*</span>
</label>
<select
Comment thread
Copilot marked this conversation as resolved.
aria-label="Land"
:value="modelValue"
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-white focus:border-lime focus:outline-none"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<option value="" :disabled="required">Bitte Land auswählen</option>
<option v-for="country in BILLING_COUNTRIES" :key="country.code" :value="country.code">
{{ country.name }}
</option>
</select>
</div>
</template>
32 changes: 10 additions & 22 deletions nuxt-app/components/tickets/TicketStepBilling.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
<script setup lang="ts">
import { useTicketCheckoutStore } from '~/composables/useTicketCheckoutStore'
import { computed, onMounted, watch } from 'vue'
import TicketCountrySelect from './TicketCountrySelect.vue'
import TicketPricingSummary from './TicketPricingSummary.vue'

const emit = defineEmits(['validityChange'])
const store = useTicketCheckoutStore()

// Validate email format
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}

// Check if form is valid
const isFormValid = computed(() => {

Check warning on line 17 in nuxt-app/components/tickets/TicketStepBilling.vue

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Code duplication

11 duplicated lines (51 tokens) 2 instances found. Also in: → components/tickets/TicketStepAttendees.vue:6-16 Extract a shared function to eliminate this duplication.
// Purchaser info is always required
const purchaserValid =
store.purchaser.firstName.trim() !== '' &&
Expand Down Expand Up @@ -249,16 +250,10 @@
</div>
</div>

<div>
<label class="text-gray-400 mb-2 block text-sm font-bold"> Land </label>
<input
:value="store.personalAddress.country || ''"
type="text"
placeholder="Deutschland"
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-white placeholder-gray-500 focus:border-lime focus:outline-none"
@input="updatePersonalAddressField('country', ($event.target as HTMLInputElement).value)"
/>
</div>
<TicketCountrySelect
:model-value="store.personalAddress.country || ''"
@update:model-value="updatePersonalAddressField('country', $event)"
/>
</div>
</div>
</div>
Expand Down Expand Up @@ -336,18 +331,11 @@
</div>
</div>

<div>
<label class="text-gray-400 mb-2 block text-sm font-bold">
Land <span class="text-lime">*</span>
</label>
<input
:value="store.company.address.country"
type="text"
placeholder="Deutschland"
class="w-full rounded-lg border border-gray-700 bg-gray-800 px-4 py-3 text-white placeholder-gray-500 focus:border-lime focus:outline-none"
@input="updateCompanyField('address.country', ($event.target as HTMLInputElement).value)"
/>
</div>
<TicketCountrySelect
:model-value="store.company.address.country"
required
@update:model-value="updateCompanyField('address.country', $event)"
/>

<div>
<label class="text-gray-400 mb-2 block text-sm font-bold"> USt-IdNr. (optional) </label>
Expand Down
7 changes: 5 additions & 2 deletions nuxt-app/components/tickets/TicketStepReview.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useTicketCheckoutStore } from '~/composables/useTicketCheckoutStore'
import { formatBillingCountry } from 'shared-code'
import { computed, onMounted, ref, watch } from 'vue'
import TicketPricingSummary from './TicketPricingSummary.vue'

Expand Down Expand Up @@ -110,7 +111,7 @@ async function proceedToPayment() {
<p>{{ store.company.address.line1 }}</p>
<p v-if="store.company.address.line2">{{ store.company.address.line2 }}</p>
<p>{{ store.company.address.postalCode }} {{ store.company.address.city }}</p>
<p>{{ store.company.address.country }}</p>
<p>{{ formatBillingCountry(store.company.address.country) }}</p>
<p v-if="store.company.billingEmail" class="mt-2 text-sm">
Rechnungs-E-Mail: {{ store.company.billingEmail }}
</p>
Expand All @@ -126,7 +127,9 @@ async function proceedToPayment() {
<p v-if="store.personalAddress.postalCode || store.personalAddress.city">
{{ store.personalAddress.postalCode }} {{ store.personalAddress.city }}
</p>
<p v-if="store.personalAddress.country">{{ store.personalAddress.country }}</p>
<p v-if="store.personalAddress.country">
{{ formatBillingCountry(store.personalAddress.country) }}
</p>
</div>
</div>
</div>
Expand Down
19 changes: 15 additions & 4 deletions nuxt-app/composables/useTicketCheckoutStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { PurchaseType, TicketType } from '~/types/directus'
import type { BillingAddress, CompanyBillingInfo, Purchaser, TicketAttendee } from '~/types/items'
import { defineStore } from 'pinia'
import { DEFAULT_BILLING_COUNTRY, normalizeBillingCountry } from 'shared-code'

interface TicketPricingSettings {
earlyBirdPriceCents: number | null
Expand Down Expand Up @@ -35,6 +36,14 @@
pricingError: boolean
}

/**
* State saved before the country became a select holds free text ("Deutschland"). Map it to the
* ISO code the select and the API expect; an unknown value is cleared so it has to be picked again.
*/
function withCountryCode(address: BillingAddress): BillingAddress {
return { ...address, country: normalizeBillingCountry(address.country) ?? '' }
}

// Keys to persist (exclude transient state like isLoading, error, discountValidating)
const PERSISTED_KEYS: (keyof TicketCheckoutState)[] = [
'conferenceId',
Expand Down Expand Up @@ -173,7 +182,7 @@
/**
* Get ticket type based on current pricing
*/
ticketType(): TicketType {

Check warning on line 185 in nuxt-app/composables/useTicketCheckoutStore.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Unused store member

Store member 'useTicketCheckoutStore.ticketType' is never accessed by any consumer. Consider removing the unused store state, getter, or action.
if (this.isEmployeeCode) {
return 'discounted'
}
Expand Down Expand Up @@ -314,9 +323,11 @@
this.attendees = stored.attendees || [{ firstName: '', lastName: '', email: '' }]
this.purchaser = stored.purchaser || { firstName: '', lastName: '', email: '' }
this.purchaseType = stored.purchaseType || 'personal'
this.company = stored.company || null
this.company = stored.company
? { ...stored.company, address: withCountryCode(stored.company.address) }
: null
this.showPersonalAddress = stored.showPersonalAddress || false
this.personalAddress = stored.personalAddress || null
this.personalAddress = stored.personalAddress ? withCountryCode(stored.personalAddress) : null
this.discountCode = stored.discountCode || ''
this.discountValid = false

Expand Down Expand Up @@ -370,7 +381,7 @@
/**
* Copy purchaser info to first attendee
*/
copyPurchaserToFirstAttendee() {

Check warning on line 384 in nuxt-app/composables/useTicketCheckoutStore.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Unused store member

Store member 'useTicketCheckoutStore.copyPurchaserToFirstAttendee' is never accessed by any consumer. Consider removing the unused store state, getter, or action.
if (this.attendees.length > 0) {
this.attendees[0] = { ...this.purchaser }
this.persistState()
Expand Down Expand Up @@ -409,7 +420,7 @@
line1: '',
city: '',
postalCode: '',
country: 'Deutschland',
country: DEFAULT_BILLING_COUNTRY,
},
}
}
Expand All @@ -436,7 +447,7 @@
line1: '',
city: '',
postalCode: '',
country: 'Deutschland',
country: DEFAULT_BILLING_COUNTRY,
}
} else if (!show) {
this.personalAddress = null
Expand Down Expand Up @@ -546,7 +557,7 @@
/**
* Reset the store to initial state and clear localStorage
*/
reset() {

Check warning on line 560 in nuxt-app/composables/useTicketCheckoutStore.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Unused store member

Store member 'useTicketCheckoutStore.reset' is never accessed by any consumer. Consider removing the unused store state, getter, or action.
const conferenceSlug = this.conferenceSlug
this.$reset()
if (conferenceSlug) {
Expand All @@ -567,7 +578,7 @@
/**
* Clear persisted data for current conference
*/
clearPersistedData() {

Check warning on line 581 in nuxt-app/composables/useTicketCheckoutStore.ts

View workflow job for this annotation

GitHub Actions / nuxt-app-test

Unused store member

Store member 'useTicketCheckoutStore.clearPersistedData' is never accessed by any consumer. Consider removing the unused store state, getter, or action.
if (this.conferenceSlug) {
clearStorage(this.conferenceSlug)
}
Expand Down
14 changes: 8 additions & 6 deletions nuxt-app/server/utils/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MEETUP_HEARD_ABOUT_OPTIONS } from 'shared-code'
import { BILLING_COUNTRY_CODES, MEETUP_HEARD_ABOUT_OPTIONS } from 'shared-code'
import { z } from 'zod'

export const EmailSchema = z.object({
Expand Down Expand Up @@ -90,6 +90,10 @@ export const PurchaserSchema = z.object({
.max(200, 'Deine E-Mail-Adresse darf nicht länger als 200 Zeichen sein.'),
})

// ISO 3166-1 alpha-2 code from the checkout's country select, stored as
// `ticket_orders.billing_country` and needed for e-invoices.
const BillingCountrySchema = z.enum(BILLING_COUNTRY_CODES, { error: 'Bitte wähle ein Land aus der Liste aus.' })

export const BillingAddressSchema = z.object({
line1: z
.string()
Expand All @@ -101,18 +105,16 @@ export const BillingAddressSchema = z.object({
.string()
.min(1, 'Bitte trage die Postleitzahl ein.')
.max(20, 'Die Postleitzahl darf nicht länger als 20 Zeichen sein.'),
country: z
.string()
.min(1, 'Bitte trage das Land ein.')
.max(100, 'Das Land darf nicht länger als 100 Zeichen sein.'),
country: BillingCountrySchema,
})

export const OptionalBillingAddressSchema = z.object({
line1: z.string().max(200, 'Die Adresszeile darf nicht länger als 200 Zeichen sein.').optional(),
line2: z.string().max(200, 'Die Adresszeile darf nicht länger als 200 Zeichen sein.').optional(),
city: z.string().max(100, 'Die Stadt darf nicht länger als 100 Zeichen sein.').optional(),
postalCode: z.string().max(20, 'Die Postleitzahl darf nicht länger als 20 Zeichen sein.').optional(),
country: z.string().max(100, 'Das Land darf nicht länger als 100 Zeichen sein.').optional(),
// An unselected country (empty string) counts as "not provided".
country: z.preprocess((value) => (value === '' ? undefined : value), BillingCountrySchema.optional()),
})

export const CompanyBillingSchema = z.object({
Expand Down
Loading
Loading