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 { 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);
}