Legal Documents and Consent
How the Terms, Privacy, Cookie and Refund policies are published, how visitors and users accept them, and what the code records as proof.
The policy texts are drafts written for Guatemala (Constitución arts. 24 and 31, Decretos 57-2008, 47-2008 and 006-2003) with notes for visitors from the EU, UK, Brazil and California. Have a Guatemalan lawyer review them, and fill in the legal entity, before relying on them.
Where things live
| Piece | Location |
|---|---|
| Document text (es + en) | apps/landing-page/src/content/{es,en}/{terms,privacy,cookies,refunds}.ts |
| Renderer (one for all four) | apps/landing-page/src/components/legal/LegalDocument.tsx, routed by components/templates/LegalPageTemplate.tsx |
| Legal entity, contact, version | apps/landing-page/src/content/legal-identity.ts |
| Shared version and slugs | packages/global/constants/legal.ts (PWA + backend) |
| Cookie banner and consent store | apps/landing-page/src/components/consent/, src/lib/consent.ts |
| Lead form consent fields | apps/landing-page/src/components/forms/ConsentFields.tsx |
| PWA links and acceptance | apps/frontend-pwa/src/components/legal/ (LegalLinks, AcceptTermsGate) |
The PWA does not keep its own copy of any document. It links to the landing page (getLegalDocumentUrl in src/lib/landing-site.ts), so each policy has exactly one published text.
One version for the set
LEGAL_DOCUMENT_VERSION (an ISO date) versions the four documents together — a merchant accepts the set.
- The backend accepts only the current value (
@IsIn([LEGAL_DOCUMENT_VERSION])) on leads and on user acceptance, so a stale bundle fails loudly instead of recording consent to a document that is no longer published. - The landing page is built without
packages/globaland keeps a copy inlegal-identity.ts.legal-pages.test.tsreads the global file and fails if the two differ.
Changing a document
- Edit the text in both locales and update
lastUpdatedDate. - If the change is material, bump
LEGAL_DOCUMENT_VERSIONin bothpackages/global/constants/legal.tsandapps/landing-page/src/content/legal-identity.ts, in the same commit. - Deploy the landing page first, then the backend and the PWA (see Deploy order).
A bump re-prompts every signed-in user through AcceptTermsGate, and lead forms start sending the new version.
What is recorded
| Where | Columns | Set by |
|---|---|---|
lead | privacy_policy_version, privacy_accepted_at, marketing_opt_in | POST /leads; the server stamps the time |
user | terms_version, terms_accepted_at | POST /auth/firebase with acceptedTermsVersion (sign-up checkbox), or POST /auth/accept-terms (the gate) |
Rows captured before these columns existed stay null: backfilling a timestamp would claim a consent nobody gave. marketing_opt_in defaults to false, and only leads with it set may receive marketing — the platform leads screen exposes it.
POST /auth/accept-terms resolves the user from the verified token, never from the body, and is idempotent: accepting the version already on record keeps the original timestamp.
Who is asked, and how
- Lead forms (demo, contact, restaurants inline): a required, unticked privacy checkbox and a separate, unticked marketing checkbox that names email and WhatsApp.
- PWA sign-up: a required checkbox. Google sign-up is blocked until it is ticked; the version survives a Google redirect in
sessionStorage. - PWA sign-in: a notice only, nothing recorded. An account created from the sign-in tab (a first Google sign-in) is asked by
AcceptTermsGateinstead, so every stored acceptance comes from a deliberate act. AcceptTermsGate, mounted inPrivateRoute: blocks onboarding and the app until the stored version matches. It cannot be dismissed — only accepted or signed out of.
Cookie consent (landing page)
GA4 and the Meta Pixel load only after the visitor opts in, for every visitor. The earlier EU-only geo-block missed the UK, the rest of the EEA, Switzerland, Brazil and California.
- The choice lives in the first-party cookie
flowpos_consent(180 days, no personal data). BumpCONSENT_VERSIONinlib/consent.tswhen a category gains a new vendor; that re-asks everyone. - Accept and Reject share one style on purpose — unequal buttons invalidate consent.
- "Cookie settings" in the footer reopens the banner. Withdrawing deletes the
_ga*/_fbpcookies and switches the loaded tools off. trackEventalso checks consent, so a tracker left on the page after a withdrawal receives nothing more.- Adding a tracker: gate it on a category in
AnalyticsProvider, add its cookies to the Cookie Policy table, and bumpCONSENT_VERSION.
The PWA shows no banner: everything it stores is needed to provide the service, and it loads no analytics or advertising tools. Its Inter font is self-hosted. Sign-in backgrounds are mostly images.unsplash.com, which the browser loads directly, so Unsplash receives the visitor's IP address before sign-in. Four local photos from the landing page are in the same rotation.
Deploy order
Landing page → backend → PWA. The backend's GlobalValidationPipe strips unknown fields, so an old backend accepts leads from the new landing page (without storing consent). The new backend requires the consent fields, so deploying it first would reject every lead from the old landing page.
Tests that guard this
| Test | Guards |
|---|---|
landing-page/.../legal-pages.test.ts | Every document loads in both languages; anchors unique; one contact address; version and slugs match packages/global |
landing-page/.../AnalyticsProvider.test.tsx | Nothing loads before a decision or after Reject; each tool gated on its own category |
landing-page/.../consent-fields.test.tsx | No lead is sent without the privacy checkbox; marketing defaults off |
landing-page/.../text-contrast.test.ts, frontend-pwa/.../theme-contrast.test.ts | Text tokens clear 4.5:1 in both themes |
backend/.../create-lead.dto.spec.ts, terms-acceptance.dto.spec.ts | Missing, false or stale consent is rejected |
frontend-pwa/.../SignInPage.test.tsx, AcceptTermsGate.test.tsx | Sign-up and Google sign-up blocked without the checkbox; the gate cannot be dismissed; the sign-in background is a local /images/sign-in/ photo |
Known gaps
- The legal entity (razón social, NIT, address) is a
[SLOT]inlegal-identity.ts. - There is no data processing agreement for merchants, whose customers' and employees' data (payroll, national IDs) FlowPOS processes.
- There is no retention job for leads; the Privacy Policy describes retention as practised, not automated.