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

View File

@ -43,6 +43,24 @@ function hashString(value: string): number {
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() {
const router = useRouter();
const { coffees, loading, error } = useActiveCoffees();
@ -75,6 +93,7 @@ export default function SummaryPage() {
invoiceCity: '',
invoicePhone: '',
invoiceEmail: '',
uidNumber: '',
signingCity: '',
});
const [showThanks, setShowThanks] = useState(false);
@ -90,12 +109,27 @@ export default function SummaryPage() {
const templateVariableNames = useMemo(() => extractTemplateVariables(contractHtml), [contractHtml])
const templateVariableNamesKey = useMemo(() => templateVariableNames.join('|'), [templateVariableNames])
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
useEffect(() => {
if (!templateVariableNamesKey) return
const fullName = `${form.firstName} ${form.lastName}`.trim()
const isCompany = user?.userType === 'company' || user?.user_type === 'company'
const invoiceSame = form.invoiceSameAsShipping
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' }),
recipientName: fullName,
recipientAddress: `${form.street}, ${form.postalCode} ${form.city}`.trim(),
shippingCustomerClass: isCompany ? '' : 'checked',
shippingCompanyClass: isCompany ? 'checked' : '',
shippingCustomerClass: isCompanyCustomer ? '' : 'checked',
shippingCompanyClass: isCompanyCustomer ? 'checked' : '',
shippingFullName: fullName,
shippingStreet: form.street,
shippingPostalCode: form.postalCode,
@ -112,8 +146,8 @@ export default function SummaryPage() {
shippingPhone: form.phone,
shippingEmail: form.email,
invoiceSameAsShippingMark: invoiceSame ? '✓' : '',
invoiceCompanyClass: isCompany ? 'checked' : '',
invoiceCustomerClass: isCompany ? '' : 'checked',
invoiceCompanyClass: isCompanyCustomer ? 'checked' : '',
invoiceCustomerClass: isCompanyCustomer ? '' : 'checked',
invoiceFullName: invoiceSame ? fullName : form.invoiceFullName,
invoiceStreet: invoiceSame ? form.street : form.invoiceStreet,
invoicePostalCode: invoiceSame ? form.postalCode : form.invoicePostalCode,
@ -122,10 +156,10 @@ export default function SummaryPage() {
invoiceEmail: invoiceSame ? form.email : form.invoiceEmail,
fnCheckedClass: '',
fnNumber: '',
atuCheckedClass: '',
atuNumber: '',
entrepreneurClass: isCompany ? 'checked' : '',
consumerClass: isCompany ? '' : 'checked',
atuCheckedClass: hasValidCompanyUid ? 'checked' : '',
atuNumber: effectiveUidNumber,
entrepreneurClass: isCompanyCustomer ? 'checked' : '',
consumerClass: isCompanyCustomer ? '' : 'checked',
paymentSepaClass: form.paymentMethod === 'sepa' ? 'checked' : '',
paymentCardClass: form.paymentMethod === 'card' ? 'checked' : '',
paymentSofortClass: form.paymentMethod === 'sofort' ? 'checked' : '',
@ -134,7 +168,7 @@ export default function SummaryPage() {
fullName,
}
setContractVariables(computed)
}, [templateVariableNamesKey, form, user, signatureDataUrl])
}, [templateVariableNamesKey, form, signatureDataUrl, effectiveUidNumber, hasValidCompanyUid, isCompanyCustomer])
const populatedContractHtml = useMemo(() => {
if (!contractHtml) return null
@ -458,8 +492,9 @@ export default function SummaryPage() {
[totalPrice, shippingFee]
);
const taxAmount = useMemo(() => totalPrice * taxRate, [totalPrice, taxRate]);
const taxAmountWithShipping = useMemo(() => netWithShipping * taxRate, [netWithShipping, taxRate]);
const effectiveTaxRate = isReverseCharge ? 0 : taxRate
const taxAmount = useMemo(() => totalPrice * effectiveTaxRate, [totalPrice, effectiveTaxRate]);
const taxAmountWithShipping = useMemo(() => netWithShipping * effectiveTaxRate, [netWithShipping, effectiveTaxRate]);
const totalWithTax = useMemo(() => netWithShipping + taxAmountWithShipping, [netWithShipping, taxAmountWithShipping]);
const handleInput = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
@ -478,23 +513,24 @@ export default function SummaryPage() {
return;
}
const pick = (...values: any[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim() !== '') return value.trim();
}
return '';
};
setSubmitError(null);
setForm(prev => ({
...prev,
firstName: pick(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
lastName: pick(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
email: pick(user.email, user.mail) || prev.email,
street: pick(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,
city: pick(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(),
firstName: pickFirstString(user.firstName, user.firstname, user.givenName, user.first_name) || prev.firstName,
lastName: pickFirstString(user.lastName, user.lastname, user.familyName, user.last_name) || prev.lastName,
email: pickFirstString(user.email, user.mail) || prev.email,
street: pickFirstString(user.street, user.addressStreet, user.address?.street, user.address_line_1) || prev.street,
postalCode: pickFirstString(user.postalCode, user.zipCode, user.zip, user.addressPostalCode, user.address?.postalCode) || prev.postalCode,
city: pickFirstString(user.city, user.addressCity, user.town, user.address?.city) || prev.city,
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,
signatureDataUrl: signatureDataUrl || undefined,
uidNumber: effectiveUidNumber || undefined,
atuNumber: effectiveUidNumber || undefined,
taxMode: isReverseCharge ? 'reverse_charge' : 'standard_vat',
referred_by: typeof currentUserId === 'number' ? currentUserId : undefined,
}
console.info('[SummaryPage] subscribeAbo payload:', payload)
@ -730,10 +769,30 @@ export default function SummaryPage() {
{/* Invoice address */}
<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>
{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">
<input type="checkbox" name="invoiceSameAsShipping" checked={form.invoiceSameAsShipping} onChange={handleCheckbox} className="accent-[#1C2B4A]" />
Same as shipping address
</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 && (
<div className="grid gap-4 sm:grid-cols-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>
</div>
<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>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Total incl. tax</span>
<span className="text-xl font-extrabold text-[#1C2B4A]">{totalWithTax.toFixed(2)}</span>
</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) */}
<div className="mt-2 text-xs text-gray-700">
Selected: {totalCapsules} capsules ({totalPacks} packs of 10). Target: {selectedPlanCapsules} capsules ({requiredPacks} packs).

View File

@ -15,7 +15,8 @@ interface CompanyProfileData {
companyPhone: string
contactPersonName: string
contactPersonPhone: string
vatNumber: string
registrationNumber: string
uidNumber: string
street: string
postalCode: string
city: string
@ -44,7 +45,8 @@ const init: CompanyProfileData = {
companyPhone: '',
contactPersonName: '',
contactPersonPhone: '',
vatNumber: '',
registrationNumber: '',
uidNumber: '',
street: '',
postalCode: '',
city: '',
@ -216,7 +218,8 @@ export default function CompanyAdditionalInformationPage() {
companyPhone: profile?.phone || me?.companyPhone || prev.companyPhone,
contactPersonName: profile?.contact_person_name || me?.contactPersonName || prev.contactPersonName,
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,
postalCode: profile?.zip_code || prev.postalCode,
city: profile?.city || prev.city,
@ -281,7 +284,7 @@ export default function CompanyAdditionalInformationPage() {
const validate = () => {
const required: (keyof CompanyProfileData)[] = [
'companyName','companyEmail','companyPhone','contactPersonName','contactPersonPhone',
'vatNumber','street','postalCode','city','country','accountHolder','iban'
'street','postalCode','city','country','accountHolder','iban'
]
for (const k of required) {
if (!form[k].trim()) {
@ -414,7 +417,9 @@ export default function CompanyAdditionalInformationPage() {
zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city,
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
branch: 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>
<label className="block text-sm font-medium text-gray-700 mb-1">
VAT / Reg No. *
Registration Number (optional)
</label>
<input
name="vatNumber"
value={form.vatNumber}
name="registrationNumber"
value={form.registrationNumber}
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"
required
/>
</div>
<div className="sm:col-span-2 lg:col-span-3">