dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
3 changed files with 123 additions and 37 deletions
Showing only changes of commit 554a573c98 - Show all commits

View File

@ -33,6 +33,9 @@ export type SubscribeAboInput = {
invoiceCity?: string invoiceCity?: string
invoicePhone?: string invoicePhone?: string
invoiceEmail?: string invoiceEmail?: string
uidNumber?: string
atuNumber?: string
taxMode?: 'standard_vat' | 'reverse_charge'
signingCity?: string signingCity?: string
signatureDataUrl?: string signatureDataUrl?: string
// logged-in user id // logged-in user id
@ -99,6 +102,9 @@ export async function subscribeAbo(input: SubscribeAboInput) {
paymentMethod: input.paymentMethod || undefined, paymentMethod: input.paymentMethod || undefined,
invoiceByEmail: input.invoiceByEmail ?? false, invoiceByEmail: input.invoiceByEmail ?? false,
invoiceSameAsShipping: input.invoiceSameAsShipping ?? true, invoiceSameAsShipping: input.invoiceSameAsShipping ?? true,
uidNumber: input.uidNumber || undefined,
atuNumber: input.atuNumber || undefined,
taxMode: input.taxMode || undefined,
signingCity: input.signingCity || undefined, signingCity: input.signingCity || undefined,
signatureDataUrl: input.signatureDataUrl || undefined, signatureDataUrl: input.signatureDataUrl || undefined,
} }

View File

@ -43,6 +43,24 @@ function hashString(value: string): number {
return hash >>> 0 return hash >>> 0
} }
const HOME_COUNTRY_CODE = 'AT'
function normalizeUid(value: unknown): string {
if (typeof value !== 'string') return ''
return value.replace(/\s+/g, '').toUpperCase()
}
function isLikelyValidUid(value: string): boolean {
return /^[A-Z]{2}[A-Z0-9]{4,14}$/.test(value)
}
function pickFirstString(...values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim() !== '') return value.trim()
}
return ''
}
export default function SummaryPage() { export default function SummaryPage() {
const router = useRouter(); const router = useRouter();
const { coffees, loading, error } = useActiveCoffees(); const { coffees, loading, error } = useActiveCoffees();
@ -75,6 +93,7 @@ export default function SummaryPage() {
invoiceCity: '', invoiceCity: '',
invoicePhone: '', invoicePhone: '',
invoiceEmail: '', invoiceEmail: '',
uidNumber: '',
signingCity: '', signingCity: '',
}); });
const [showThanks, setShowThanks] = useState(false); const [showThanks, setShowThanks] = useState(false);
@ -90,12 +109,27 @@ export default function SummaryPage() {
const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml]) const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml])
const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames]) const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames])
const [contractVariables, setContractVariables] = useState<Record<string, string>>({}) const [contractVariables, setContractVariables] = useState<Record<string, string>>({})
const isCompanyCustomer = user?.userType === 'company' || user?.user_type === 'company'
const profileUidNumber = useMemo(() => normalizeUid(pickFirstString(
user?.uidNumber,
user?.uid_number,
user?.atuNumber,
user?.atu_number,
user?.companyProfile?.uid_number,
user?.companyProfile?.atu_number
)), [user])
const enteredUidNumber = useMemo(() => normalizeUid(form.uidNumber), [form.uidNumber])
const effectiveUidNumber = enteredUidNumber || profileUidNumber
const hasValidCompanyUid = isCompanyCustomer && isLikelyValidUid(effectiveUidNumber)
const isForeignInvoiceCountry = form.country.toUpperCase() !== HOME_COUNTRY_CODE
const isReverseCharge = isCompanyCustomer && hasValidCompanyUid && isForeignInvoiceCountry
// Auto-compute contract variables from form state for preview // Auto-compute contract variables from form state for preview
useEffect(() => { useEffect(() => {
if (!templateVariableNamesKey) return if (!templateVariableNamesKey) return
const fullName = `${form.firstName} ${form.lastName}`.trim() const fullName = `${form.firstName} ${form.lastName}`.trim()
const isCompany = user?.userType === 'company' || user?.user_type === 'company'
const invoiceSame = form.invoiceSameAsShipping const invoiceSame = form.invoiceSameAsShipping
const computed: Record<string, string> = { const computed: Record<string, string> = {
@ -103,8 +137,8 @@ export default function SummaryPage() {
currentDate: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }), currentDate: new Date().toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }),
recipientName: fullName, recipientName: fullName,
recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(), recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(),
shippingCustomerClass: isCompany ? '' : 'checked', shippingCustomerClass: isCompanyCustomer ? '' : 'checked',
shippingCompanyClass: isCompany ? 'checked' : '', shippingCompanyClass: isCompanyCustomer ? 'checked' : '',
shippingFullName: fullName, shippingFullName: fullName,
shippingStreet: form.street, shippingStreet: form.street,
shippingPostalCode: form.postalCode, shippingPostalCode: form.postalCode,
@ -112,8 +146,8 @@ export default function SummaryPage() {
shippingPhone: form.phone, shippingPhone: form.phone,
shippingEmail: form.email, shippingEmail: form.email,
invoiceSameAsShippingMark: invoiceSame ? '✓' : '', invoiceSameAsShippingMark: invoiceSame ? '✓' : '',
invoiceCompanyClass: isCompany ? 'checked' : '', invoiceCompanyClass: isCompanyCustomer ? 'checked' : '',
invoiceCustomerClass: isCompany ? '' : 'checked', invoiceCustomerClass: isCompanyCustomer ? '' : 'checked',
invoiceFullName: invoiceSame ? fullName : form.invoiceFullName, invoiceFullName: invoiceSame ? fullName : form.invoiceFullName,
invoiceStreet: invoiceSame ? form.street : form.invoiceStreet, invoiceStreet: invoiceSame ? form.street : form.invoiceStreet,
invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode, invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode,
@ -122,10 +156,10 @@ export default function SummaryPage() {
invoiceEmail: invoiceSame ? form.email : form.invoiceEmail, invoiceEmail: invoiceSame ? form.email : form.invoiceEmail,
fnCheckedClass: '', fnCheckedClass: '',
fnNumber: '', fnNumber: '',
atuCheckedClass: '', atuCheckedClass: hasValidCompanyUid ? 'checked' : '',
atuNumber: '', atuNumber: effectiveUidNumber,
entrepreneurClass: isCompany ? 'checked' : '', entrepreneurClass: isCompanyCustomer ? 'checked' : '',
consumerClass: isCompany ? '' : 'checked', consumerClass: isCompanyCustomer ? '' : 'checked',
paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '', paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '',
paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '', paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '',
paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '', paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '',
@ -134,7 +168,7 @@ export default function SummaryPage() {
fullName, fullName,
} }
setContractVariables(computed) setContractVariables(computed)
}, [templateVariableNamesKey, form, user, signatureDataUrl]) }, [templateVariableNamesKey, form, signatureDataUrl, effectiveUidNumber, hasValidCompanyUid, isCompanyCustomer])
const populatedContractHtml = useMemo(() => { const populatedContractHtml = useMemo(() => {
if (!contractHtml) return null if (!contractHtml) return null
@ -458,8 +492,9 @@ export default function SummaryPage() {
[totalPrice, shippingFee] [totalPrice, shippingFee]
); );
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]); const effectiveTaxRate = isReverseCharge ? 0 : taxRate
const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]); const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]);
const taxAmountWithShipping = useMemo(() => netWithShipping * effectiveTaxRate, [netWithShipping, effectiveTaxRate]);
const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]); const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
@ -478,23 +513,24 @@ export default function SummaryPage() {
return; return;
} }
const pick = (...values: any[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return '';
};
setSubmitError(null); setSubmitError(null);
setForm(prev => ({ setForm(prev => ({
...prev, ...prev,
firstName: pick(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName, firstName: pickFirstString(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
lastName: pick(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName, lastName: pickFirstString(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
email: pick(user.email, user.mail) || prev.email, email: pickFirstString(user.email, user.mail) || prev.email,
street: pick(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street, street: pickFirstString(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street,
postalCode: pick(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode, postalCode: pickFirstString(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode,
city: pick(user.city, user.addressCity, user.town, user.address?.city) || prev.city, city: pickFirstString(user.city, user.addressCity, user.town, user.address?.city) || prev.city,
country: (pick(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(), country: (pickFirstString(user.country, user.countryCode, user.addressCountry, user.address?.country) || prev.country).toUpperCase(),
uidNumber: normalizeUid(pickFirstString(
user.uidNumber,
user.uid_number,
user.atuNumber,
user.atu_number,
user.companyProfile?.uid_number,
user.companyProfile?.atu_number
) || prev.uidNumber),
})); }));
}; };
@ -576,6 +612,9 @@ export default function SummaryPage() {
} : {}), } : {}),
signingCity: form.signingCity.trim() || undefined, signingCity: form.signingCity.trim() || undefined,
signatureDataUrl: signatureDataUrl || undefined, signatureDataUrl: signatureDataUrl || undefined,
uidNumber: effectiveUidNumber || undefined,
atuNumber: effectiveUidNumber || undefined,
taxMode: isReverseCharge ? 'reverse_charge' : 'standard_vat',
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined, referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
} }
console.info('[SummaryPage] subscribeAbo payload:', payload) console.info('[SummaryPage] subscribeAbo payload:', payload)
@ -730,10 +769,30 @@ export default function SummaryPage() {
{/* Invoice address */} {/* Invoice address */}
<div className="mt-6 border-t border-gray-200 pt-4"> <div className="mt-6 border-t border-gray-200 pt-4">
<h3 className="text-base font-semibold text-gray-900 mb-3">Invoice address</h3> <h3 className="text-base font-semibold text-gray-900 mb-3">Invoice address</h3>
{isCompanyCustomer && (
<div className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Unternehmer mit gueltiger UID und Rechnungsland ausserhalb von {HOME_COUNTRY_CODE} werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.
</div>
)}
<label className="flex items-center gap-2 text-sm mb-3"> <label className="flex items-center gap-2 text-sm mb-3">
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" /> <input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Same as shipping address Same as shipping address
</label> </label>
{isCompanyCustomer && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">UID Number (optional)</label>
<input
name="uidNumber"
value={form.uidNumber}
onChange={handleInput}
placeholder="z.B. SI12345678"
className="w-full rounded border px-3 py-2 bg-white border-gray-300 uppercase focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
<p className="mt-1 text-xs text-gray-600">
Ohne gueltige UID wird die Rechnung mit normaler MwSt erstellt.
</p>
</div>
)}
{!form.invoiceSameAsShipping && ( {!form.invoiceSameAsShipping && (
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
@ -902,13 +961,18 @@ export default function SummaryPage() {
<span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{netWithShipping.toFixed(2)}</span> <span className="text-lg font-extrabold tracking-tight text-[#1C2B4A]">{netWithShipping.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm">Tax ({(taxRate * 100).toFixed(1)}%)</span> <span className="text-sm">{isReverseCharge ? 'Tax (Reverse Charge)' : `Tax (${(effectiveTaxRate * 100).toFixed(1)}%)`}</span>
<span className="text-sm font-medium">{taxAmountWithShipping.toFixed(2)}</span> <span className="text-sm font-medium">{taxAmountWithShipping.toFixed(2)}</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-semibold">Total incl. tax</span> <span className="text-sm font-semibold">Total incl. tax</span>
<span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span> <span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span>
</div> </div>
{isReverseCharge && (
<div className="mt-2 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Reverse Charge aktiv: gueltige UID und auslaendisches Rechnungsland erkannt.
</div>
)}
{/* Validation summary (refined design) */} {/* Validation summary (refined design) */}
<div className="mt-2 text-xs text-gray-700"> <div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs). Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).

View File

@ -15,7 +15,8 @@ interface CompanyProfileData {
companyPhone: string companyPhone: string
contactPersonName: string contactPersonName: string
contactPersonPhone: string contactPersonPhone: string
vatNumber: string registrationNumber: string
uidNumber: string
street: string street: string
postalCode: string postalCode: string
city: string city: string
@ -44,7 +45,8 @@ const init: CompanyProfileData = {
companyPhone: '', companyPhone: '',
contactPersonName: '', contactPersonName: '',
contactPersonPhone: '', contactPersonPhone: '',
vatNumber: '', registrationNumber: '',
uidNumber: '',
street: '', street: '',
postalCode: '', postalCode: '',
city: '', city: '',
@ -216,7 +218,8 @@ export default function CompanyAdditionalInformationPage() {
companyPhone: profile?.phone || me?.companyPhone || prev.companyPhone, companyPhone: profile?.phone || me?.companyPhone || prev.companyPhone,
contactPersonName: profile?.contact_person_name || me?.contactPersonName || prev.contactPersonName, contactPersonName: profile?.contact_person_name || me?.contactPersonName || prev.contactPersonName,
contactPersonPhone: profile?.contact_person_phone || me?.contactPersonPhone || prev.contactPersonPhone, contactPersonPhone: profile?.contact_person_phone || me?.contactPersonPhone || prev.contactPersonPhone,
vatNumber: profile?.registration_number || prev.vatNumber, registrationNumber: profile?.registration_number || prev.registrationNumber,
uidNumber: profile?.uid_number || profile?.atu_number || prev.uidNumber,
street: profile?.address || prev.street, street: profile?.address || prev.street,
postalCode: profile?.zip_code || prev.postalCode, postalCode: profile?.zip_code || prev.postalCode,
city: profile?.city || prev.city, city: profile?.city || prev.city,
@ -281,7 +284,7 @@ export default function CompanyAdditionalInformationPage() {
const validate = () => { const validate = () => {
const required: (keyof CompanyProfileData)[] = [ const required: (keyof CompanyProfileData)[] = [
'companyName','companyEmail','companyPhone','contactPersonName','contactPersonPhone', 'companyName','companyEmail','companyPhone','contactPersonName','contactPersonPhone',
'vatNumber','street','postalCode','city','country','accountHolder','iban' 'street','postalCode','city','country','accountHolder','iban'
] ]
for (const k of required) { for (const k of required) {
if (!form[k].trim()) { if (!form[k].trim()) {
@ -414,7 +417,9 @@ export default function CompanyAdditionalInformationPage() {
zip_code: form.postalCode, // Backend expects 'zip_code' zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city, city: form.city,
country: form.country, country: form.country,
registrationNumber: form.vatNumber, // Map VAT number to registration number registrationNumber: form.registrationNumber || undefined,
uidNumber: form.uidNumber || undefined,
atuNumber: form.uidNumber || undefined,
businessType: 'company', // Default business type businessType: 'company', // Default business type
branch: null, // Not collected in form, set to null branch: null, // Not collected in form, set to null
numberOfEmployees: null, // Not collected in form, set to null numberOfEmployees: null, // Not collected in form, set to null
@ -580,15 +585,26 @@ export default function CompanyAdditionalInformationPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
VAT / Reg No. * Registration Number (optional)
</label> </label>
<input <input
name="vatNumber" name="registrationNumber"
value={form.vatNumber} value={form.registrationNumber}
onChange={handleChange} onChange={handleChange}
placeholder="e.g. DE123456789" placeholder="e.g. FN123456a"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
UID Number (optional)
</label>
<input
name="uidNumber"
value={form.uidNumber}
onChange={handleChange}
placeholder="e.g. ATU12345678"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent" className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm uppercase focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
required
/> />
</div> </div>
<div className="sm:col-span-2 lg:col-span-3"> <div className="sm:col-span-2 lg:col-span-3">