import { Capacitor } from "@capacitor/core"; import { NativePurchases, PURCHASE_TYPE } from "@capgo/native-purchases"; import { storeNaam } from "./platform"; import { betalingFout } from "./plugin-fouten"; import { log } from "./log"; /* * Een plek kopen, aan de kant van het toestel. * * Betalen gaat via de store en niet via ons: Google (en straks Apple) toont het bedrag, incasseert * en is de verkoper. Wat de app terugkrijgt is een bonnetje, en dat is het enige dat naar de server * gaat — die trekt het daar na (server/billing.js). De app weet dus nooit iets van een betaling * behalve dat hij gelukt is, en dat is precies genoeg. * * Twee dingen die hier belangrijker zijn dan ze lijken: * * 1. Het bonnetje wordt bewaard zodra het binnen is, vóór de server het gezien heeft. Tussen betalen * en registreren kan van alles gebeuren — de verbinding valt weg, de app wordt weggeveegd — en dan * is er geld weg zonder account. Zolang de bon in localStorage staat kan de app het bij de * volgende start gewoon afmaken; hij is pas verbruikt als er een account op staat. * 2. De aankoop wordt hier niet afgehandeld bij de store (autoAcknowledgePurchases: false). Dat doet * de server, ná het aanmaken van het account, en die consumeert hem (server/billing.js) — anders * blijft de plek eigendom van dit Google-account en is er nooit een tweede te koop. Een aankoop * die niet binnen drie dagen verwerkt is draait Google automatisch terug, en dat is maar goed ook: * het is de enige vangnetregel die de koper zijn geld teruggeeft als het bij ons misgaat. */ const KEY = "liefdesbrieven.purchase"; /* * Hoe lang we een bonnetje bewaren dat Google zelf niet kent. Vlak na het betalen geeft Google soms * 404 op een aankoop die wél bestaat, dus meteen weggooien zou het bewijs vernietigen van iemand die * echt betaald heeft. Maar een token dat nooit heeft bestaan blijft anders eeuwig opnieuw ingediend * worden, en dan komt de gebruiker er nooit meer voorbij. Een etmaal is ruim genoeg voor het eerste * geval en kort genoeg voor het tweede. */ export const PURCHASE_STALE_MS = 24 * 60 * 60 * 1000; /* Kan er op dit toestel überhaupt betaald worden? In de browser (npm run dev) niet: er is geen store. */ export const canPay = () => Capacitor.isNativePlatform(); /* * Het bewaarde, nog niet verzilverde bonnetje — of null. `at` is het moment van kopen; bonnetjes van * vóór die toevoeging krijgen hier alsnog een stempel, zodat ze een etmaal de tijd krijgen in plaats * van meteen als oud te gelden. Anders zou een app-update het bewijs weggooien van iemand die op dat * moment net midden in het afrekenen zat. */ export function pendingPurchase() { try { const raw = localStorage.getItem(KEY); if (!raw) return null; const p = JSON.parse(raw); if (p && !p.at) { p.at = Date.now(); savePurchase(p); } return p; } catch (e) { /* Onleesbaar bonnetje: er valt niets te tonen, maar dit is geld — dus wel in het logboek */ log("✗ pendingPurchase", e?.message ?? String(e)); return null; } } /* Is dit bonnetje zo oud dat "Google kent hem niet" niet meer aan vertraging kan liggen? */ export const purchaseIsStale = (p) => !p?.at || Date.now() - p.at > PURCHASE_STALE_MS; const savePurchase = (p) => { try { localStorage.setItem(KEY, JSON.stringify(p)); } catch (e) { /* geen storage: dan alleen deze sessie, en dat is nog altijd beter dan niets */ log("✗ savePurchase", e?.message ?? String(e)); } }; /* Het account staat er (of de bon bleek al gebruikt): weg ermee, anders duikt hij bij elke start op */ export const clearPendingPurchase = () => { try { localStorage.removeItem(KEY); } catch (e) { /* zie boven */ log("✗ clearPendingPurchase", e?.message ?? String(e)); } }; /* * Wat een plek kost, in de valuta van de koper en zoals de store het zelf schrijft ("€ 3,99"). * Null als het niet op te vragen is — dan noemt de app geen bedrag in plaats van een verkeerd bedrag. */ export async function priceOf(productId) { if (!canPay()) return null; try { const { product } = await NativePurchases.getProduct({ productIdentifier: productId, productType: PURCHASE_TYPE.INAPP }); return product?.priceString ?? null; } catch (e) { /* Geen bedrag is beter dan een verkeerd bedrag; waaróm het niet lukte hoort wel ergens te staan */ log("✗ priceOf", e?.message ?? String(e)); return null; } } /* * De aankoop zelf. Geeft het bonnetje terug in de vorm die POST /api/auth/register verwacht, en * bewaart het onderweg. Gooit een fout met `zacht: true` als er niets kapot is en het scherm hem als * mededeling hoort te brengen in plaats van als storing. * * Het heette `cancelled`, tot bleek dat we dat niet kunnen weten: de plugin geeft bij elk niet-OK * antwoord van Play dezelfde zin en gooit de responseCode weg. "Zacht" is wat we er nog wél over * kunnen zeggen, en een naam die niet meer belooft dan hij waarmaakt. */ export async function buySlot(productId) { if (!canPay()) { const err = new Error("Betalen kan alleen in de app zelf, niet in de browser."); err.zacht = false; throw err; } const saved = pendingPurchase(); if (saved?.productId === productId) return saved; // nog een openstaande bon: niet twee keer laten betalen /* * Werkt de kassa hier überhaupt? Play Billing doet alleen iets als de app uit de Play Store komt. * Een zijgeladen build (een APK van de bouwer, een testtoestel) kan dus niet betalen, en zonder * deze controle krijgt de gebruiker een technische storingstekst voor iets wat geen storing is * maar een eigenschap van zijn installatie. Faalt de vraag zelf, dan gaan we gewoon door: dan * spreekt de kassa zich zo meteen zelf wel uit, en een plugin-eigenaardigheid mag geen betaling * tegenhouden die het gewoon zou doen. */ try { const { isBillingSupported } = await NativePurchases.isBillingSupported(); if (!isBillingSupported) { const err = new Error( `Betalen kan op dit toestel niet: de app moet uit ${storeNaam()} komen. Installeer hem daar, dan werkt het wel.` ); err.zacht = false; throw err; } } catch (err) { if (err.zacht === false) throw err; /* de vraag zelf mislukte — doorgaan, de kassa geeft zo nodig zelf een fout */ } let transaction; try { transaction = await NativePurchases.purchaseProduct({ productIdentifier: productId, productType: PURCHASE_TYPE.INAPP, quantity: 1, isConsumable: true, // een plek is eenmalig: wie er nog een wil, koopt opnieuw autoAcknowledgePurchases: false, // bevestigen doet de server, pas als het account er echt is }); } catch (err) { /* * Hier stond een test op het woord "cancel" in de foutboodschap. Die matchte nooit: de plugin * geeft bij élk niet-OK antwoord van Play dezelfde zin ("Purchase is not purchased") en gooit de * responseCode weg, waar het verschil in zit. Wie de betaalkaart wegtikte kreeg dus een rode * storing met een Engelse zin erin. Zie lib/plugin-fouten.js voor wat we er wél van kunnen zeggen. */ log("✗ purchaseProduct", err?.message ?? String(err)); const { zin, zacht } = betalingFout(err); const wrapped = new Error(zin); wrapped.zacht = zacht; throw wrapped; } const token = transaction?.purchaseToken || transaction?.jwsRepresentation || transaction?.transactionId; if (!token) { const err = new Error("De store gaf geen bewijs van betaling terug. Neem contact op als er wel afgeschreven is."); err.zacht = false; throw err; } const purchase = { provider: Capacitor.getPlatform() === "ios" ? "apple" : "google", token, productId, at: Date.now() }; savePurchase(purchase); return purchase; }