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