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.

14.7 kB Ruw bestand
import { readFileSync } from "node:fs";
import { createSign } from "node:crypto";
import { SIGNUP_PRODUCT_ID, GENERAL_CONTACT } from "../src/constants.js";

/*
 * Een betaalde plek natrekken bij de store.
 *
 * Wat hier níet gebeurt is betalen. De store is de verkoper: Google (en straks Apple) int het geld,
 * doet de btw, kent de klant en handelt de terugbetaling af. Wat er bij ons binnenkomt is een
 * bonnetje — één ondoorzichtige tekenreeks — en de enige vraag die deze module beantwoordt is: is
 * die bon echt, gaat hij over ons artikel, en is er ook werkelijk betaald? Daarom staat er in de
 * database geen naam, geen bedrag en geen kaartnummer (zie db/schema.sql), en hoeft de
 * privacyverklaring geen betaaldienstverlener als verwerker te noemen.
 *
 * Natrekken moet, en wel hier op de server: de app draait op het toestel van de koper, dus alles wat
 * de app zegt ("ik heb betaald") is precies wat er nagemaakt wordt. Het bonnetje is pas een bewijs
 * als Google het bevestigt.
 *
 * Instellen (buiten git, zie README):
 *   PLAY_PACKAGE           het pakket in de Play Console (standaard nl.bramluiken.liefdesbrieven)
 *   PLAY_SERVICE_ACCOUNT   het sleutelbestand van het serviceaccount, als pad óf als JSON
 *
 * Staat de sleutel er niet, dan weigert de server betaalde registratie met een duidelijke fout in
 * plaats van hem door te laten: een onbevestigd bonnetje is geen bonnetje.
 */

const PACKAGE = process.env.PLAY_PACKAGE ?? "nl.bramluiken.liefdesbrieven";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const API = "https://androidpublisher.googleapis.com/androidpublisher/v3";
const SCOPE = "https://www.googleapis.com/auth/androidpublisher";

/*
 * purchaseState in de server-API van Google: 0 = betaald, 1 = geannuleerd, 2 = in behandeling.
 * Let op: aan de kant van de app telt de billing-library andersom (PURCHASED = 1) — een verwisseling
 * die precies één keer gemaakt hoeft te worden om elke bon goed te keuren. Dit is de serverkant.
 */
const PURCHASED = 0;
const PENDING = 2;

/* Het sleutelbestand van het serviceaccount: als pad, of als JSON in de omgevingsvariabele zelf */
function loadServiceAccount() {
  const raw = process.env.PLAY_SERVICE_ACCOUNT?.trim();
  if (!raw) return null;
  try {
    const key = JSON.parse(raw.startsWith("{") ? raw : readFileSync(raw, "utf8"));
    if (!key.client_email || !key.private_key) throw new Error("client_email of private_key ontbreekt");
    return key;
  } catch (err) {
    console.error(`PLAY_SERVICE_ACCOUNT kon niet gelezen worden (${err.message}); betaalde registratie staat uit`);
    return null;
  }
}

const KEY = loadServiceAccount();

/* Kan de server een bon natrekken? Zo niet, dan is er geen betaalde registratie mogelijk. */
export const billingReady = () => KEY !== null;

/*
 * Toegangstoken bij Google, via de standaard serviceaccount-dans: een JWT die we zelf ondertekenen
 * met de privésleutel, ingewisseld voor een token van een uur. Bewaard tot een minuut voor het
 * einde — een aankoop mag niet stukgaan op een token dat tijdens het verzoek verloopt.
 * Geen bibliotheek nodig: het is één handtekening en één POST.
 */
let cached = { token: null, until: 0 };

async function accessToken() {
  if (cached.token && Date.now() < cached.until) return cached.token;

  const now = Math.floor(Date.now() / 1000);
  const part = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
  const unsigned = `${part({ alg: "RS256", typ: "JWT" })}.${part({
    iss: KEY.client_email,
    scope: SCOPE,
    aud: TOKEN_URL,
    iat: now,
    exp: now + 3600,
  })}`;
  const assertion = `${unsigned}.${createSign("RSA-SHA256").update(unsigned).sign(KEY.private_key, "base64url")}`;

  const res = await fetch(TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok || !data.access_token)
    throw new Error(`Google gaf geen toegangstoken (${res.status}): ${data.error_description ?? data.error ?? "onbekende fout"}`);

  cached = { token: data.access_token, until: Date.now() + Math.max(60, (data.expires_in ?? 3600) - 60) * 1000 };
  return cached.token;
}

/* Eén POST op het bonnetje: `:consume` of `:acknowledge`. Gooit als Google het niet aanneemt. */
async function bonPost(actie, productId, token) {
  const res = await fetch(
    `${API}/applications/${PACKAGE}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}:${actie}`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${await accessToken()}`, "Content-Type": "application/json" },
      body: "{}",
    }
  );
  if (!res.ok) throw new Error(`${res.status}: ${(await res.text()).slice(0, 200)}`);
}

/*
 * Google laten weten dat de plek uitgegeven is. Dat moet: een aankoop die binnen drie dagen niet
 * verwerkt is draait Google automatisch terug — de koper krijgt zijn geld terug en wij houden een
 * account over waar niet voor betaald is. Het gebeurt hier en niet in de app, want de app kan
 * sluiten tussen betalen en verwerken; de server weet zeker dát de plek uitgegeven is.
 *
 * **Consumeren, niet alleen bevestigen**, en dat verschil bepaalt of iemand ooit kan terugkomen. Een
 * eenmalig artikel dat wel bevestigd maar niet geconsumeerd is, blijft bij Google eigendom van dat
 * Google-account: hij verkoopt er nooit een tweede ("already owned"). Wie zijn account wist zou dan
 * nergens meer heen kunnen — zijn oude bon is hier verbruikt en een nieuwe is niet te koop. Zo'n
 * levenslange uitsluiting is niet wat er betaald is. Consumeren legt het artikel terug in de winkel,
 * zodat een plek gewoon opnieuw te koop is voor wie er weer een wil.
 *
 * Dat maakt de bak niet lekker: één bon blijft één account, en die bewaking zit op `unique (provider,
 * token)` in de database, niet in de winkel. Wie tien accounts wil betaalt tien keer — precies wat
 * dit ontwerp bedoelt.
 *
 * Consumeren bevestigt ook, dus de drie-dagenregel is er net zo goed mee afgedekt. Lukt het niet, dan
 * bevestigen we alsnog: die koper kan later geen tweede plek kopen, maar hij raakt tenminste niet
 * zijn geld kwijt aan een teruggedraaide aankoop terwijl zijn account gewoon bestaat.
 *
 * Best effort, en met opzet ná het aanmaken van het account: mislukt het, dan is dat een probleem
 * van ons met Google en niet van de gebruiker met zijn account. Het staat in het log.
 */
async function consume(productId, token) {
  try {
    await bonPost("consume", productId, token);
    return;
  } catch (err) {
    console.error(`aankoop consumeren mislukt (${err.message}) — we proberen hem alleen te bevestigen`);
  }
  try {
    await bonPost("acknowledge", productId, token);
  } catch (err) {
    console.error(`aankoop bevestigen mislukt: ${err.message}`);
  }
}

/*
 * Is dit bonnetje goed voor één plek? Geeft { ok: true, orderId } of { ok: false, error, status, code },
 * waarbij `error` bedoeld is om letterlijk aan de gebruiker te tonen en `status` de HTTP-code is
 * waarmee het verzoek eindigt.
 *
 * `code` bestaat voor één beslissing aan de app-kant: **bewaren of weggooien**. De app bewaart het
 * bonnetje tot er een account op staat, want tussen betalen en registreren kan de verbinding wegvallen
 * — maar een bon die nooit meer goed komt moet juist weg, anders dient de app hem bij elke poging
 * opnieuw in en komt de gebruiker er nooit voorbij. Dat is precies wat er gebeurde: een aankoop die
 * Google had teruggedraaid bleef in localStorage staan en gaf eindeloos "Deze betaling is niet
 * doorgegaan".
 *
 * `dood` betekent: deze bon wordt nooit meer geldig, gooi weg en laat opnieuw kopen.
 * `wacht` en `onbekend` kunnen vanzelf goed komen (een betaling die nog loopt, een aankoop die Google
 * nog niet ziet) en horen bewaard te blijven.
 *
 * Wat hier níet gecontroleerd wordt is of de bon al gebruikt is: dat weet Google niet, dat weten
 * wij. De unique-sleutel op `purchases` is die controle, en die zit in dezelfde transactie als het
 * aanmaken van het account (server/queries.js) — het enige moment waarop het antwoord niet meteen
 * weer verouderd is.
 */
/*
 * Hoe de winkel heet in een tekst die de gebruiker leest. Afgeleid uit `provider` en niet vast
 * ingetikt: de meldingen hieronder noemen de winkel bij naam, en dat is nu goed omdat een Apple-bon
 * er nooit komt (die valt eerder af met 501). Zodra Apple er wél bij komt, staat er anders "Google"
 * op een iPhone — en niets in de code zou daar dan nog aan herinneren.
 */
const winkelNaam = (provider) => (provider === "apple" ? "de App Store" : "Google");

export async function verifyPurchase({ provider, token, productId }) {
  /* Een bon voor een ander artikel of een andere winkel geeft hier nooit een account — weg ermee,
     anders blijft hij bij elke poging opnieuw langskomen. */
  if (productId !== SIGNUP_PRODUCT_ID)
    return { ok: false, status: 400, code: "purchase_dead", error: "Dit artikel geeft geen recht op een account." };

  if (provider === "apple")
    return {
      ok: false,
      status: 501,
      error: "Betalen via de App Store kan nog niet. Mail privacy@liefdesbrieven.bramluiken.org als je vastloopt.",
    };
  if (provider !== "google")
    return { ok: false, status: 400, code: "purchase_dead", error: "Onbekende betaalwijze." };

  /*
   * Hier komen betekent dat de gratis bak leeg is én de kassa stuk: dan komt er niemand meer binnen.
   * Daarom noemt deze tekst een adres. "Probeer het later opnieuw" zou de gebruiker laten wachten op
   * iets wat vanzelf niet goed komt, en ons niets laten weten.
   */
  if (!billingReady())
    return {
      ok: false,
      status: 503,
      code: "purchase_pending",
      error: `Betalen werkt hier op dit moment niet — dat ligt aan ons, niet aan jou. Laat het weten op ${GENERAL_CONTACT}, dan lossen we het op.`,
    };

  let purchase;
  try {
    const res = await fetch(
      `${API}/applications/${PACKAGE}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`,
      { headers: { Authorization: `Bearer ${await accessToken()}` } }
    );
    /* Bewaren: vlak na het betalen ziet Google zijn eigen aankoop soms nog niet, en dan is weggooien
       precies het verkeerde — dan is er wél betaald en gooit de app het bewijs weg. */
    if (res.status === 404 || res.status === 410)
      return { ok: false, status: 402, code: "purchase_unknown", error: `We vinden deze aankoop niet bij ${winkelNaam(provider)}. Is de betaling gelukt, probeer het dan zo nog eens.` };
    /*
     * 400 "Invalid Value" is iets anders dan "nog niet zichtbaar": Google zegt dat dit bonnetje geen
     * geldige waarde ís, en dat wordt het nooit meer. Gemeten (21-08-2026): met een goede sleutel
     * geeft een verzonnen token 400, niet 404. Zonder deze tak viel dat in de 502 hieronder — zonder
     * code, dus de app bewaarde een kapot bonnetje en diende het eeuwig opnieuw in.
     */
    if (res.status === 400)
      return {
        ok: false,
        status: 402,
        code: "purchase_dead",
        error: "Dit betaalbewijs is niet bruikbaar. Je kunt het opnieuw proberen.",
      };
    if (!res.ok) {
      console.error(`aankoop natrekken mislukt (${res.status}): ${(await res.text()).slice(0, 200)}`);
      return { ok: false, status: 502, error: `We konden je aankoop nu niet bij ${winkelNaam(provider)} nakijken. Probeer het zo nog eens.` };
    }
    purchase = await res.json();
  } catch (err) {
    console.error(`aankoop natrekken mislukt: ${err.message}`);
    return { ok: false, status: 502, error: `We konden je aankoop nu niet bij ${winkelNaam(provider)} nakijken. Probeer het zo nog eens.` };
  }

  /* Nog onderweg: bewaren, die kan alsnog goedgekeurd worden. */
  if (purchase.purchaseState === PENDING)
    return { ok: false, status: 402, code: "purchase_pending", error: `De betaling is nog niet rond. Zodra ${winkelNaam(provider)} hem goedkeurt kun je verder.` };
  /* Geannuleerd of teruggedraaid: dit bonnetje komt nooit meer goed. */
  if (purchase.purchaseState !== PURCHASED)
    return {
      ok: false,
      status: 402,
      code: "purchase_dead",
      error: "Deze betaling is niet doorgegaan of is teruggedraaid. Je kunt het opnieuw proberen.",
    };

  /*
   * Verwerken mag pas als de plek echt uitgegeven is, dus dat doet de aanroeper na afloop
   * (grantedPurchase). Hier alleen het antwoord.
   */
  return { ok: true, orderId: purchase.orderId ?? "" };
}

/*
 * Werkt de hele betaalketen nog? Sleutel → token → rechten op dít pakket. Voor het gezondheidsrapport,
 * want dit is de stilste storing die er is: raakt de sleutel verlopen of het serviceaccount zijn
 * rechten kwijt, dan komt er — met een lege gratis bak — geen enkel nieuw lid meer binnen, en het
 * enige spoor is een regel in het log die niemand leest.
 *
 * De truc is een bonnetje dat gegarandeerd niet bestaat. Gemeten (21-08-2026) tegen de echte sleutel:
 *
 *   kapotte sleutel                    → 401   authenticatie gaat vóór alles
 *   goede sleutel, verzonnen bonnetje  → 400   "Invalid Value"
 *
 * Een 400 betekent dus dat we voorbij de sleutel én de rechten zijn en dat alleen het bonnetje niet
 * deugt — precies wat we willen weten. 401 of 403 zegt het omgekeerde. Het artikel opvragen
 * (`inappproducts.get`) zou mooier zijn maar geeft 403 "Please migrate to the new publishing API".
 * Er wordt niets gewijzigd en niemand geraakt.
 */
export async function billingSelfTest() {
  if (!billingReady()) return { ok: false, stap: "configuratie", uitleg: "PLAY_SERVICE_ACCOUNT ontbreekt of is onleesbaar" };

  let tok;
  try {
    tok = await accessToken();
  } catch (err) {
    return { ok: false, stap: "token", uitleg: err.message };
  }

  try {
    const res = await fetch(
      `${API}/applications/${PACKAGE}/purchases/products/${encodeURIComponent(SIGNUP_PRODUCT_ID)}/tokens/zelftest-bestaat-niet`,
      { headers: { Authorization: `Bearer ${tok}` } }
    );
    if (res.status === 400 || res.status === 404 || res.status === 410)
      return { ok: true, stap: "rechten", uitleg: `${PACKAGE} · artikel ${SIGNUP_PRODUCT_ID}` };
    if (res.status === 401 || res.status === 403)
      return { ok: false, stap: "sleutel of rechten", uitleg: `Google weigert (${res.status}): het serviceaccount komt niet bij ${PACKAGE}` };
    return { ok: false, stap: "antwoord", uitleg: `onverwachte status ${res.status} van Google` };
  } catch (err) {
    return { ok: false, stap: "verbinding", uitleg: err.message };
  }
}

/*
 * Het account staat er: de aankoop is definitief. Consumeren bij de store, zodat Google hem niet na
 * drie dagen terugdraait én de koper later gewoon opnieuw een plek kan kopen. Wacht niet op het
 * antwoord — de gebruiker is klaar, dit is huishouding.
 */
export function grantedPurchase({ provider, productId, token }) {
  if (provider === "google") void consume(productId, token);
}