Quotes | Cine Power Planner

Quotes (Angebote) — User Guide

Module: /accounting/quotes Status: Beta — gated by a server-set per-account flag (is_quotes_enabled). It is enabled per account on the backend (and is always on in development builds). There is no self-serve Settings toggle — a user cannot enable Quotes themselves. Languages: German (primary, legally binding) + English (informational).


Overview

The Quotes module turns Cine Power Planner into the single source of truth for the pre-sale offer document that a production company sends a client before the rental starts. Instead of leaving the app for sevdesk / Lexware / Invoice Ninja to draft an Angebot and then re-keying the data back when converting to an invoice, you create quotes natively. Accepted quotes flow to invoices and contracts with one click and full traceability.

The module is built around four jobs:

  1. Draft — line items pulled from projects, sub-rentals, or timesheets, with markup % applied automatically.
  2. Send — finalised, gap-free quote number allocated, PDF + interactive web link issued.
  3. Accept — postal-code-challenged public page (/q/<token>) where the customer ticks options, agrees to the AGB, and accepts or declines.
  4. Convert — accepted quotes seed an invoice or a rental contract; the chain is end-to-end traceable.

Settings (one-time setup)

Before sending your first quote, complete these in Settings → Accounting → Quote settings:

  • Default validity (days) — defaults to 14. Per § 145/146 BGB, a quote without a stated validity expires "unverzüglich" and is no longer binding.
  • Default quote-number prefix — defaults to ANG. Numbering is gap-free per fiscal year (ANG-2026-0001, ANG-2026-0002, …).
  • Default markup % — applied to source-derived line items when no per-customer markup is set on the contact.
  • Accepted payment methods — surfaced on the B2C disclosure block (§ 312j Abs. 2 BGB requires showing accepted payment methods before B2C acceptance).

And in Settings → Accounting → Invoice settings, under Impressum (§ 5 TMG):

  • Company name — your registered legal name. Required for every company form except Einzelunternehmen: a sole proprietor has no Firma unless registered as e.K., so § 5 DDG is satisfied by your own name. Leave it empty and the Impressum footer, the Art. 13 DSGVO controller sentence and the PDF footer all print your own name instead. Fill it in if you trade under a business name — it then wins everywhere.
  • Company form (GmbH, UG, AG, KG, GbR, Einzelunternehmen, Verein)
  • Handelsregister / Registergericht / Geschäftsführer*in (for GmbH-style companies)
  • Vereinsregister / Vorstand (for Vereine)
  • Steuernummer + USt-IdNr.
  • Datenschutz-URL (link to your privacy policy — surfaced on the public acceptance page). Leave it empty and the Art. 13 block reads "Vollständige Datenschutzerklärung auf Anfrage" rather than pointing your customer at Cine Power Planner's privacy notice.

Company name, address and Datenschutz-URL are what your customer sees on the public page. They are read live from your profile when the /q/<token> link is opened and drive both the Art. 13 DSGVO controller sentence and the § 355 BGB Widerrufsbelehrung — the same values, resolved the same way, as the Impressum footer on the quote PDF. With no company name stored the page falls back to your own display name (correct for an Einzelunternehmen), and only if that is missing too does it show the app name.

The first time you try to send a quote with an incomplete Impressum, the editor will block you with a deep-link back to the missing fields. Per § 5 TMG + § 14 Abs. 4 UStG, the Impressum is mandatory on outbound business documents.


Workflow

1. Create a quote

Three entry points:

  • + New quote — blank draft.
  • + From source — pick a project, sub-rental, or timesheet. The mapper auto-seeds the line items with the right unit prices and applies your markup.
  • Apply template — start from a saved QuoteTemplate (Settings → Accounting → Quote templates) to reuse common headers, footers, AGB attachments, and starter line items. The template editor itself asks before discarding: once you have changed anything in it, Escape, the ✕ and Cancel all prompt rather than closing. A template you only opened still closes on the first press.

Reopening a quote from the list routes by lifecycle: draft and pending rows open the editor, because nothing is locked until the quote is sent. Every other status (sent, accepted, declined, expired, converted_*, cancelled, superseded) opens the read-only detail view with the audit-event timeline. Deep links behave the same way — opening /accounting/quotes/<id> for a draft redirects to its editor.

Sub-rental seeding (the differentiator)

When you "+ From source → Sub-rental", the mapper reads the latest accepted price baseline from the sub-rental's gear_request_quotes (the inbound offer from your rental house) and stamps each line with that exact version. Your markup is applied on top. Every line on the customer-facing quote carries sourceRef = {kind:'subrental', id, gearRequestQuoteId, gearRequestQuoteVersion} for the full audit trail.

Markup precedence: quote.markupPercent (set per quote on the header) → contact.defaultMarkupPercent (per-customer default) → 0%.

Getting back to the sub-rental: the editor's Linked documents card carries a Sub-rental chip that opens that sub-rental's own edit form (via /subrentals?edit=<id> — there is no /subrentals/:id route; the form is a portal modal over the list). The chip resolves the sub-rental from sourceRefs first, so it works for a seeded quote with no project, and falls back to the project's regular link otherwise. A ref whose sub-rental no longer loads renders disabled with an "unavailable" hint.

2. Build the line items

Five line kinds:

KindCounts toward total?Customer interaction
regularYesNone — accepted as-is
alternativeNo (in parentheses on the PDF)Customer picks exactly one per alternativeGroupId at acceptance
optionalNoCustomer ticks 0+ at acceptance
subtotalPass-through running-total markerNone
freetextNoNone — purely informational

Alternative groups are the Lexware-style Wahlpositionen pattern; only the accepted alternative becomes a line on the converted invoice/contract.

Every regular, alternative and optional line is edited across the same five columns, named by a header strip above the list: Description · Quantity · Unit · Unit price · Tax %. An alternative group is a nested list and repeats the strip over its own rows. Below 768 px the row stacks into one column per control, each under its own label. subtotal and freetext lines have a single field and no columns, so a list holding only those shows no header.

3. Header decisions

  • Binding mode (binding ⦿ vs freibleibend ◯) — defaults to binding per § 145 BGB. Set freibleibend to issue a non-binding price indication; the public acceptance page disables the Accept button and the customer must contact you back.
  • Contract type (mietvertrag / werkvertrag / mixed) — defaults to mietvertrag (most equipment rentals). Changes the Verjährungsklausel (§ 548 BGB vs § 634a BGB) inserted into the AGB block.
  • Display mode (net / gross) — for B2C customers (customerType=consumer), gross is FORCED per PAngV.
  • Tax treatmentstandard (19% / 7% per line), kleinunternehmer (VAT suppressed per § 19 UStG), reverse_charge (§ 13b UStG cross-border B2B), or outside_eu (§ 3a UStG).

For reverse-charge quotes, the editor validates the customer's USt-IdNr against the EU VIES service automatically and stamps viesValidatedAt on the quote row. If VIES is unreachable, you can still send the quote — a footnote on the PDF says "VIES nicht erreichbar — manuelle Verifikation empfohlen" and the audit log records the failure.

4. Send

Clicking "Send":

  1. Allocates a gap-free quote number from the per-fiscal-year counter.
  2. Computes contentHashAtSent (SHA-256 of the canonical line items + customer + totals + global discount + body copy: Anschreiben, terms and closing note).
  3. Locks all of the above 25+ fields (3-layer enforcement: client validator + Supabase RLS + DB trigger).
  4. Issues a 256-bit hex token for the public acceptance page.
  5. Optionally emails the customer via Send by email modal (subject/body templated, AGB attachment included).

If you're offline when you click Send, the action is enqueued via quoteQueueService; the number is allocated the next time the device comes online. The quote stays in pending status with full edit/cancel access until the counter drains.

Integrity check on a sent quote

Every time you open a quote that has been sent, the app recomputes that fingerprint and compares it to the one frozen at send. If they differ, a red "Integrity check failed" banner appears at the top of the editor and the detail page, showing both digests, and the PDF preview is flagged as not being the offer on record.

The banner is a warning, never a block. Your customer's acceptance link keeps working exactly as before — nothing about their experience changes. It exists to tell you that the document you are looking at is no longer what went out, so you can review it before re-sending or converting it to an invoice.

The check runs only on your own screens. It is deliberately not run on the customer's acceptance page: that page receives a reduced copy of the quote with your buy prices and margins removed, so it cannot reproduce the fingerprint even for a perfectly untouched offer.

5. Customer acceptance

The customer opens https://cine-power-planner.com/q/<token> and walks through 4 stages:

  1. Postal-code challenge — they enter their postal code; 3 wrong attempts inside a 60-second window trigger a 5-minute lockout for the link itself. The counter lives on the quote-link row (postal_failed_attempts / postal_window_started_at / postal_locked_until) and bump_quote_link_postal_throttle(token, success) takes no IP, so switching network does not reset it — and while the lockout is active even the correct postal code is refused. (Earlier revisions of this page said the lockout was scoped to a “token+IP pair”. It is not, and the difference cuts both ways: a customer cannot dodge it by moving to mobile data, and an attacker gets 3 attempts per link rather than 3 per address. The per-IP throttle in this app is the signing throttle, a different control.)
  2. Quote preview + DSGVO disclosure — the full PDF in an iframe + the Art. 13 DSGVO block (controller, purpose, legal basis, retention, link to your Datenschutzerklärung). You are named as the Verantwortliche*r, from your own Impressum — see § Settings above for which fields feed it.
  3. Selections + AGB + (B2C) Widerrufsbelehrung — pick alternatives, tick optionals, acknowledge the AGB (full § 305 Abs. 2 BGB Einbeziehung ritual: link + inline content + checkbox), and — for consumers — the 14-day Widerrufsbelehrung with the Muster-Widerrufsformular.
  4. Accept or decline — final total + delivery / payment-method disclosure (B2C) + Accept or Decline (with optional reason).

On accept:

  • An immutable quote_acceptance_events row is recorded with hashed IP, hashed UA, postal-code-verified-at timestamp, selections, and content-hash-at-event (for tamper detection).
  • You get an in-app notification + email.
  • The customer keeps a 30-day signed-PDF download link satisfying § 312i Abs. 1 Nr. 4 (retrievable contract terms).

6. Convert to invoice or contract

Once a quote is accepted:

  • Convert to invoice seeds an invoice draft with all the accepted lines (alternatives reduced to the selected one; optionals filtered by the customer's selections). ⚠️ The § 14 Abs. 2 UStG six-month deadline is measured from acceptedAt — the date the customer accepted — not from the Leistungszeitraum end-date, and it BLOCKS the conversion rather than warning about it. Both paths enforce it identically and deliberately (the manual convertToInvoice returns DEADLINE_EXCEEDED; the realtime auto-spawn returns deadlineExceeded), and there is no override control — the documented escape is to Storno the quote and draft a fresh one with current dates. Plan around the acceptance date: a quote accepted long before the shoot starts its clock at acceptance, not at delivery.
  • Convert to contract opens a contract draft using the new rental_agreement template (or werkvertrag template if the quote's contract type is werkvertrag). The contract inherits the line items, dates, and totals.

Both conversions stamp sourceRefs.push({kind:'quote', id}) on the downstream document so the chain is end-to-end traceable from quote ID to invoice ID and/or contract ID.


Gear, crew & the project pipeline (v0.396.0)

Quotes are now the front door of a whole production:

  • Gear section: Add gear opens a picker over your Owned Gear and Gear Sets. Picked items become priced positions (day rate × units × rental days). Sets are ONE position at the set price with their members listed. Units and days stay editable per line.
  • Crew & rates: add crew lines with role, day rate, base hours and surcharges (simple percentages or a collective agreement). Star one line as This is me — it seeds from your default rate (Settings → Accounting → Quote settings → My default rate) and can be saved back from any quote. Each line chooses Time-tracked (a timesheet is created with the project) and/or Show as quote position. A budget-only line (e.g. a sound operator you pay yourself) appears in the project budget but creates no timesheet. The Per diem (Spesen) toggle adds a BMF-flat-rate position.
  • Terms with placeholders: the Terms editor understands {{baseRate}}, {{baseHours}}, {{overtimeRate}}, {{nightSurcharge}}, {{sundaySurcharge}}, {{holidaySurcharge}}, {{perDiemRate}}, {{perDiemDays}}, {{shootDays}}, {{periodStart}}, {{periodEnd}}, {{role}}, {{currency}}. Insert engagement template drops a complete DE/EN clause set already filled from Crew & rates; Fill placeholders resolves tokens in your own text.
  • Create project: the toolbar (and the Linked-documents card) offers Create project — it materializes the project with the quoted gear preconfigured (your gear as provider), the crew (incl. budget-only lines), shoot schedule and rental dates, a seeded planned budget, an automatically linked sub-rental for the handover documents, and one timesheet project per time-tracked crew line (rate, base hours and surcharges prefilled). Everything is crosslinked; the Linked-documents card navigates to project, sub-rental, timesheets and the converted invoice.
  • Reverse direction: creating a quote draft from a project (Budget tab → Create quote draft) now brings the owned gear as linked positions, crew rates from the project and its linked timesheets, the service period, and suggests per diems when the timesheet tracked any.
  • Invoices from actuals: timesheet-linked invoice drafts bill tracked labor, per diems, day-logged gear and open billable expenses; the invoice editor's Pull from actuals card inserts whatever is still missing, without double-billing. The Budget tab can re-seed planned values from the quote (Seed budget from quote, with an explicit overwrite confirm).

Silence is NOT acceptance

Unlike some B2B contexts where Schweigen auf ein kaufmännisches Bestätigungsschreiben (silence on a confirmation letter) counts as acceptance per German Handelsbrauch, this app never treats silence as acceptance. A quote stays in sent until the customer explicitly accepts, declines, or it expires (validUntil date passes). This is a deliberate conservative-by-design choice — operators get a clean audit trail and never have to litigate "did the customer's silence count?".

Werkvertrag vs Mietvertrag (§ 535 vs § 631 BGB)

  • Mietvertrag (default) — pure equipment use, 6-month limitation period per § 548 BGB.
  • Werkvertrag — bundled service (operator + equipment), 2-year (or 5-year for buildings) limitation per § 634a BGB.
  • Mixed — both clauses are inserted; each applies per service component.

Pick the correct type when drafting. If unsure, default mietvertrag is the safer choice for most film/photo equipment rentals.

Long-term Mietvertrag (§ 550 BGB)

Mietverträge longer than one year need Schriftform (qualified electronic signature or wet ink). The postal-code-challenge acceptance is Textform (§ 126b BGB) and does NOT bind to the long-term minimum. The editor warns when you draft a quote with a service period > 365 days; you can still send it, but the contract becomes "unbestimmte Zeit" (indeterminate). Best practice: convert to a Schriftform-signed contract via the Contracts module.

AGB inclusion (§ 305 Abs. 2 BGB)

For the AGB to legally apply to the contract, three cumulative requirements must be met:

  1. Explicit notice (link to AGB visible on the acceptance page).
  2. Reasonable opportunity to read (inline collapsible content with the full AGB body).
  3. Explicit agreement (checkbox: "Ich habe die AGB zur Kenntnis genommen und stimme ihnen zu.").

The app enforces all three. At send-time the AGB body is SHA-256 hashed; the acceptance event records exactly which version the customer agreed to. If you ever revise the AGB, old quotes stay bound to the older version.

B2C compliance

When the customer is flagged customerType=consumer:

  • The 14-day Widerrufsbelehrung is rendered on the quote PDF and the acceptance page (per § 312g BGB).
  • Prices are forced to gross display (per PAngV).
  • The Stage-4 disclosure block shows accepted payment methods + delivery limits (per § 312i + § 312j BGB).
  • A retrievable signed-PDF download link valid for 30 days is emitted on acceptance.
  • The Muster-Widerrufsformular is included with the PDF.

GoBD retention (§ 147 AO)

All quotes past draft status are retained for 10 years. Hard-delete is blocked by a DB trigger; only soft-delete (deleted_at) is allowed. Acceptance and audit events are append-only (UPDATE/DELETE refused even by the row owner — only service_role can purge after the retention period). The GoBD Z3 export panel in Settings → Security & Privacy → Privacy & Legal generates a Steuerprüfung-ready ZIP per the BMF Beschreibungsstandard v1.1.

DSGVO Art. 17 erasure vs § 147 AO retention

Customers may request data erasure. The Settings → Security & Privacy → Privacy & Legal → Anonymize contact (DSGVO § 17) tool replaces the customer's PII (name, email, address, USt-IdNr) on linked quotes/invoices/contracts with [anonymisiert YYYY-MM-DD] while preserving the financial integrity (totals, tax, invoice number, content hash). The audit trail stays intact for the 10-year retention; only the personal data is redacted.

Active quotes (pending / sent) cannot be anonymized — the operator must wait for accept/decline/expiry or invoke Storno first.


Storno (correction)

A sent quote cannot be edited. To correct a mistake, click Storno in the editor toolbar. The system allocates a new quote number, inserts a superseded Stornoquote row with negated line items, and flips the original to cancelled. Both rows are kept in the audit trail.

After Storno, draft a new quote with the corrected details.


Pricing & competition

Compared to the DACH market:

FeaturesevdeskLexwareCine Power Planner
Quotes module
Sub-rental → quote markup chain✅ (with gear_request_quoteVersion traceability)
Postal-code-challenged acceptance
Real-time accept notification (no webhook polling)partial
Alternative + optional positions (Lexware style)
Full § 305 BGB AGB ritual with hash-locked attachmentpartialpartial
Werkvertrag vs Mietvertrag distinctionpartialpartial
GoBD Z3 Datenträgerüberlassung export
VIES VAT-ID auto-validation for reverse-chargepartialpartial

Troubleshooting

  • "VIES nicht erreichbar" — the EU portal occasionally returns HTTP 5xx. You can still send; the warning footnote appears on the PDF. Re-validate manually at https://ec.europa.eu/taxation_customs/vies/ and update the quote.
  • "Diese Subrental ist mit aktiven Angeboten verknüpft und kann nicht storniert werden" — a customer-facing quote (status sent, accepted, or converted) references this sub-rental. Either Storno the downstream quote first or wait for it to expire.
  • "§ 14 Abs. 2 UStG: Rechnungsausstellungsfrist abgelaufen" — the quote was accepted more than 6 months ago (the deadline runs from acceptedAt, not from the service end-date). This is a block, not a dismissible warning, and there is no acknowledge-and-proceed control: Storno the quote and draft a new one with current dates.
  • "Integritätsprüfung fehlgeschlagen" / "Integrity check failed" — the sent quote no longer matches its send-time fingerprint. Nothing is blocked and the customer link still works. Compare the quote against the PDF you sent (or the acceptance page the customer sees) before re-sending or converting it. If the digests differ but you cannot find any content change, note both hashes from the banner and report it.
  • "Postal-code locked for 5 minutes" — the customer entered the wrong postal code 3 times. They need to wait or contact you to verify the address.

Change Log

  • 2026-08-30 — v0.704.0: sent quotes are re-verified against their send-time fingerprint on every read (owner-facing warn banner); the Anschreiben now participates in that fingerprint.
  • 2026-07-09 — v0.396.0: gear & sets as linked positions, crew & rates (engagement terms) with own-rate defaults, tokenized T&C template, create-project pipeline (gear + crew + budget + sub-rental + timesheets), enriched reverse seeding, pull-from-actuals invoicing, budget seeding. See docs/features/quote-project-pipeline/.
  • 2026-05-23 — Initial release of the Quotes (Angebote) feature. Beta-gated.

Last Updated: 2026-09-10 Version: 0.790.2