Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

7.7 kB Ruw bestand
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;
}