Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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;
}