Commit Graph

3 Commits (v1.3.54)

Author SHA1 Message Date
nexxo 9da1358802 Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT
line, the note on the invoice — and was still charged 214,80 € for a 179,00 €
package, because one Stripe Price carried the domestic gross for everybody.
There is no VAT line on their document to reclaim, so it was a flat 20 %
surcharge on exactly the customers who read their invoices, and the document
then stated the whole 214,80 € as net at 0 %, so they self-accounted their own
VAT on a base a fifth too large.

A Stripe Price can ask who is buying after all: there are two per sellable
thing now, the domestic gross and the bare net, both live on one Product, and
the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B
without VAT — a domestic business still pays the gross, reclaims it as input
tax, and the price on the website is still the price charged for them.

- stripe:sync-catalogue mints and archives both halves of every pair, per plan
  and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices
  says which is which, and joining it on subscriptions.stripe_price_id says
  which one a running contract is billed on. A rate change moves the gross
  Price and leaves the net one alone, because the net owes nothing to the rate.
- A status that changes after the sale converges: a verification (or a lapse)
  makes stripe:reprice-subscriptions move the contract onto the other Price
  with PRORATE_NONE, because the term is already paid for. The module items
  follow the same way.
- Downstream follows: the invoice total equals what was taken, the proof
  register expects the figure this customer is actually charged (so a correct
  reverse-charge sale is no longer flagged as a mismatch, and an overcharged
  one is), the setup fee obeys the same rule, and the booking page quotes what
  it will charge.
- StripeClient gained activatePrice(). Archived Prices were brought back in our
  own table and left inactive at Stripe, so a rate that moved and moved back
  pointed the catalogue at a Price no checkout could be opened for.

An unverified number is still charged the gross — an unchecked string must
never be a discount — and where the net Price is missing the checkout refuses
rather than reaching for the domestic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:15:41 +02:00
nexxo f02e86769b Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Stripe was charging the catalogue's NET figure while the document added the
domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced
for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax
office whether or not it ever arrived, so every document issued created a
liability against revenue that did not contain it.

The Stripe Price now carries the GROSS figure. price_cents stays net - it is
frozen onto every contract and PlanChange prorates against it, so redefining it
would corrupt every pro-rata sum ever computed. Only the amount at the till
moved, and it is formed by the one call TaxTreatment already answers for the
price sheet and for the invoice, so displayed, charged and invoiced cannot drift
apart.

Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax
authority here, and a second rate computed by Stripe would take 19 % from a
German consumer while our document said 20 %.

A Stripe Price cannot be edited, so a changed figure means a new Price and the
old one archived - which stops it being SOLD and leaves every subscription
already on it billing the old amount for ever. stripe:reprice-subscriptions is
the second half, and a command of its own because it touches live money.

A withdrawing consumer is refunded in full, as the owner decided. That is more
generous than FAGG para 16 requires, and it makes the express-request consent the
statute hinges on irrelevant - so the gate is gone rather than left looking
load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:42:02 +02:00
nexxo 8f1630ba91 Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in
the month it was booked and never again. And the renewal document was written
from our own contract snapshot, so it would not have said so even if the money
had been taken. Two halves of one fault, closed together.

Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per
module on both terms — a Stripe Price carries its own interval, and a booking
frozen at an older figure needs its own Price, so they are keyed by module,
amount, currency and interval in stripe_addon_prices. Booking adds an item with
always_invoice: prorated by days AND charged there and then, which is the
invoice the owner wants for a module booked mid-term. A second storage pack is
a quantity on one item, not a second item. Cancelling takes the item off with
no proration — no credit, and the next cycle is simply smaller — while the
module itself is kept until the period end on subscription_addons.cancels_at
and ended by clupilot:end-cancelled-addons.

Documents are built from Stripe's own invoice lines. Stripe is what charged the
customer; a document assembled from our figures would state a sum that is not
the one taken. So every paid Stripe invoice gets one document with one number,
idempotent on stripe_invoice_id — a cycle carrying package and every module
together, a mid-period booking carrying just its prorated line, an upgrade
carrying its proration. Only the wording is ours: each line is named from the
catalogue rather than printed as a Price id, and a line nobody can name stays on
the document under Stripe's own description and is logged. The checkout's own
invoice is still the one exception; that purchase already has a document.

Stripe being away never undoes a booking that has already reached the machine.
The failure is parked on the contract in stripe_addon_sync and swept by the
same clupilot:sync-stripe-subscriptions that retries a plan change, which finds
its work from the bookings themselves rather than from the marker. A granted
contract has no Stripe subscription and is not touched at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:22:02 +02:00