All reported points
Bug vs missing feature
| Point | Symptom | Verdict | Kind |
|---|---|---|---|
| 1.A Checkbox options | Cannot type comma (or the space after it) in “Checkbox options, comma separated” | Admin onChange splits on comma every keystroke and drops empty tokens, so the comma never stays in the field. |
Bug |
| 1.B RFQ chat prefills | Professional must re-ask wizard questions in chat | StartChatButton only opens a thread with professionalId. No VAT answers, package, address, or usage are posted. |
Gap |
| 3 NL B2B €0 | Dutch business + NL VAT still shows Belgian 6% | 0% reverse charge is implemented, but country comes from profile location (empty → BE). Service address is ignored. Unverified VAT keeps local rate. Nederland is not an alias. Quote pricingLines can also override 0% at checkout. |
Bug |
| 4 Peppol skipped | Admin shows UBL (Peppol: skipped) |
peppolEnabled defaults false. Skip is also returned for non-BE B2B. Odoo path creates a move and returns queued with “send via Odoo Peppol workflow” — it does not transmit. |
Bug + gap |
| 5 Dual invoices | Same PDF for customer and professional; INV- not FIX/SUP | One self-billed PDF/UBL. Issuer = platform, bill-to = customer, supplier = professional. Missing qty/units, materials, VAT answers, payment info, page numbers. Extra costs only if already on the booking at generation time. | Gap |
| Overview vs pay | €576.43 then €821.58 | Checkout quote.amount = package + extras. Payment then adds commissioned extras again, then VAT on that net. |
Bug |
| 7 Quote VAT field | Dropdown is wrong when country unknown; need comma decimals | Quotation wizard is a closed list from /vat-rates, defaulting to 21% BE. Server rejects any rate not in that list. No free numeric field. |
Gap |
| Added costs + fees | Admin payments miss completion extras; how are payout/fee calculated? | Invoice is generated on payment authorize and never rebuilt. Payment documents have no extra-cost fields. Commission is marked up for the customer, then taken again from that marked-up amount for payout. | Bug |
| Invoice email | Want PDF emailed when generated | No invoice email in emailService. Completions notify, but nothing attaches the PDF. |
Gap |
Source: 20 Aug 2026 screenshots + fixera-server/src/utils/vatManagement.ts, handlers/Stripe/payment.ts, services/invoiceGenerator.ts, services/peppolDispatch.ts
Money
Overview total is right; payment inflates it
Your read is correct. The booking-wizard “Grand Total (excl. VAT)” is package + add-ons (commission-inclusive). The payment page total is not that number plus VAT. It is that number with extras counted a second time, then VAT.
On pay-at-checkout, the server stores quote.amount = checkoutSnapshot.totalAmount, and that snapshot is baseSubtotal + extraOptionsTotal. Payment then does:
chargeAmount = quote.amount * (1 + commission%)
chargeAmount += extras * (1 + commission%)
Harness with package 410.68 + extras 80 + 20% commission: overview customer net 588.82. Payment net if quote already includes extras: 684.82. The extra 96 is exactly 80 × 1.20 charged twice.
€576.43 → €821.58 is the same shape plus VAT. If payment is 21% on (overview + one extra commissioned-options add), implied net is 678.99 and the second extras add is 102.56. That matches “original service amount already includes the options.”
The payment UI tries to split “Original Service Amount” vs “Selected Options” by prorating originalAmount against quote.amount + options. That split is cosmetic. Stripe is charged totalWithVat from the inflated net. The schedule step on the same page also lists quote.amount (already including extras) and then lists extras again.
Point 3
0% for NL B2B is real — the wizard never reaches it
Unit tests already expect verified NL B2B to reverse-charge to 0%. Belgian B2B is intentionally not exempt (B2B_SAME_AS_B2C_COUNTRIES = BE, CH, LI, NO, GR). That part of the product spec is in code.
The Rotterdam screenshot still shows “Estimated VAT (6% reduced rate)” and “Renovation of dwelling older/equal than 10 years — 6% VAT”. That is a Belgian reduced rule. Three gates all have to pass for 0%, and the wizard fails them:
| Gate | Required | What the wizard actually uses |
|---|---|---|
| Country | NL (not in the BE exception set) | customer.location.country only. Service address is stored as text in RFQ description. Empty country normalizes to BE. |
| Customer type | business |
Profile customerType. Company address on the VAT form is not the VAT country. |
| Verified VAT | isVatVerified === true and a valid number |
Format-valid is not enough. Unverified keeps 6%/21%. Dummy NL987654321B02 will fail VIES unless mocked. |
Harness: normalizeVatCountry("Nederland") → "" (unknown). "" → BE. Verified NL VAT with country still BE keeps 6% because Belgium is a same-as-B2C exception.
So a Dutch service address + Dutch company address + NL VAT number can still produce Belgian 6% if the profile location is Belgium or empty, or if the stored country string is Nederland (Dutch name, not in the alias table). The screenshot had “Same as profile address” unchecked: VAT never reads that field.
Two more paths that keep Belgian rates even after a correct NL decision:
| Path | What happens |
|---|---|
| Quotation draft create | Uses customer.location.country only — no project.distance, no service address, empty answers. |
| Checkout VAT | If the quote has complete pricingLines, those rates win over vatDecision.reverseCharge. A 6%/21% line set while country was BE still charges 6%/21% after the customer is NL B2B. |
| Country fields | VAT decisions read location.country. Peppol and invoice PDF prefer companyAddress.country. VIES may fill company country from the VAT prefix, but decisions ignore it. |
VAT number prefix is never the jurisdiction. There are no tests for booking/quotation country precedence or quote-line override vs B2B.
Point 5
One confused self-bill, generated too early
You want two legal documents with different prices and VAT:
| Document | From → to | Price | VAT example (immovable) |
|---|---|---|---|
| FIX… | Platform → customer | Customer price (commission in) | BE B2C 6%; NL B2B 0% |
| SUP… | Professional → platform | Professional price (no commission) | BE B2B reverse charge 0% |
What ships today: a single PDF numbered INV-YYYY-NNNNNN, always selfBilling: true. Header issuer is platform company settings. Bill-to is the customer. “Supplier” is the professional. That is not either of the two invoices above.
Generation runs on Stripe payment authorization. A second call at completion no-ops if PDF/UBL already exist. Extra costs declared at the end of the job are therefore too late for the invoice, which is exactly why you generate at completion.
| Requested on PDF | In generator now |
|---|---|
| Unit price and quantity when price model is units | Table columns are Description + Amount only. Snapshot has qty/unitPrice but they are not rendered as columns. |
| Included materials | Quote materials exist on quote versions; PDF ignores them. |
| Added extra costs | Mapped if present at generation. Usually empty because PDF is built at pay time. |
| Customer VAT answers | Stored on vatDecision.answers, not printed. |
| Customer payment info | Not on the PDF. |
| Page number | No PDFKit page footer. |
| Email the PDF | No invoice mailer. Completions notify without attachment. |
Layout PDFs were listed as attachments in chat; only screenshots landed in the workspace. Field gaps above are from invoiceGenerator.ts vs your list.
Point 4
Skipped is the designed default; Odoo is not a send
Admin “UBL (Peppol: skipped)” is the literal peppolDispatchStatus. Dispatch returns skipped when the booking is not Belgian B2B, or when platform eInvoicing.peppolEnabled is false (schema default).
If both gates pass and provider is Odoo, the code creates an account.move, optionally posts it, attaches UBL, and returns status queued with reason “Invoice created in Odoo; send via Odoo Peppol workflow”. There is no Peppol send API call. Status sent is never written on this path.
The 7 July Odoo drafts for “Fixera E2E Test Customer BV” (100 € + 21% = 121 €, number /, Peppol column empty) match integration-time test invoices sitting in Concept, not a working Peppol delivery. That also matches “I did receive 2 invoices when you were integrating Odoo.”
Payout
Commission is applied twice
Two different models are mixed:
| Surface | Formula |
|---|---|
| Customer price (booking wizard) | professionalAmount × (1 + commission%/100) — markup |
| Admin settings copy | example × commission% deducted, remainder to professional — take-rate |
| Actual payout | professionalPayout = commissionedNet × (1 − commission%/100) |
Harness, 20% commission, professional 490.68, customer net 588.82: professional should receive 490.68 and platform 98.14. Actual payout helper: professional 471.06, platform 117.76. That is (1+c)×(1−c) = 1−c² of the professional amount (96% at 20%), not 100%.
Stripe transfer uses booking.payment.professionalPayout. Extra costs at completion live on the booking, not the Payment row the admin oversight table reads. So the ledger never shows end-of-job extras, even when the customer paid them.
1.A · 1.B · 7
Admin commas, silent chat, closed VAT list
1.A. Controlled input value is options.join(', '). Each keystroke does split(',').map(trim).filter(Boolean). Typing a comma produces an empty token that is deleted, so the comma vanishes. Spaces inside an option (“foo bar”) work. The space after a comma cannot be typed because the comma never survives. That is the red box in the VAT admin screenshot. The same eager-parse lives on professional dropdown options in the same admin form.
1.B. “Chat for quotation” is StartChatButton with only professionalId. VAT answers exist only after booking create, on vatDecision.answers / booking detail — not in the first chat message. The professional has no structured dump of wizard fields.
7. Quotation VAT is a Select of rates from GET /api/quotations/:id/vat-rates. Country is vatDecision.country || customer.location.country || BE. Fallback option is 21% BE. Submit rejects any rate not in that list. There is no numeric field and no comma-decimal parser. Admin logic-rule rates use type="number" step={0.1}, which also fights comma locales.
When you say go
Fix order, not a patch yet
Do not start from Peppol or invoice chrome. The customer-visible money and VAT country are wrong first.
- Stop double-counting extras:
quote.amountshould be package-only, or payment must not add extras again. - Drive VAT country from service address (place of supply), with aliases for
Nederland/ Places names; require VIES for 0%. - Two invoices (FIX / SUP) with different nets and VAT; generate at completion so extras are included; email both PDFs.
- Peppol: enable + actually send from Odoo, not “create draft and skip.”
- Payout = professional net (no second commission haircut); admin payments must show completion extras.
- Admin checkbox options as a raw string until blur; quote VAT as a numeric field; RFQ chat first message from wizard answers.
Diagnosis complete 20 Aug 2026. Follow-up pass added quote-line VAT override, quotation-draft country, and field-source mismatch. Waiting to implement.