Billig + Stripe

I januar/februar 2023 ble betalingsbackenden i Billig skrevet om til å integrere mot Stripe, nærmere bestemt deres redirect-baserte løsning Stripe Checkout, dette som et resultat av godt og vel 2,5 år med høye feilrater fra API-et beskrevet i Billig Swedbank Pay. vsbugge, blodfersk og nybakt Billig-ansvarlig, og olebra stod for implementasjonen, med Sesse som stødig reviewer og veileder. Til forskjell fra de to forrige implementasjonene (Billig Swedbank Pay og Billig PayEx) prosesserer vi ikke lenger kortdata av noe slag, hvilket gjør at sikkerhetskravene til systemet senkes betraktelig; mens vi tidligere måtte innfri PCI SAQ C og etter hvert den enorme PCI SAQ D, holder det nå med PCI SAQ A. Dette medfører bl.a. at betalingsbackenden ikke lenger må leve på en egen boks (jfr. okkupasjon).

Oversikt

Kjøpsflyten er slik:

  1. Brukeren konstruerer en handlekurv e.l. på frontenden til hen er fornøyd.
  2. I samme kjøpsform fyller vedkommende også inn enten epostadresse eller medlemskortnummer.
  3. Formet POST-es til betalingsbackenden, med mål /pay.
  4. /pay gjør så all nødvendig initialisering mot Stripe og videresender brukeren til Stripes betalingsside, hvor vedkommende kan fylle inn kortinformasjon eller betale med (Apple|Google) Pay. Brukeren kan også velge å la Stripe ta vare på betalingsdetaljene hens vha. løsningen Stripe Link. Stripe velger så ev. å verifisere kunden vha. 3D Secure 2, før hen sendes til frontendens OK-side (med billettnumrene som parametre).
  5. Stripe sender så et "completed"-event til betalingsbackenden, med mål /callback, som fullfører reservasjonen, sender billettene på epost og markerer dem som aktive i databasen.

Et oversiktlig sekvensdiagram over denne prossesen kan hentes fra dokumentasjonen til Stripe.

Dersom feil skulle oppstå (feil epostadresse/medlemskortnummer, null billetter valgt, arrangementet er utsolgt, mm.) blir brukeren sendt til frontendens feilside med et session-parameter. Session-parameteret peker til en spesiell tabell i Billig-databasen hvor feilmelding ligger, samt nok informasjon til å rekonstruere handlekurven (slik at brukeren ikke trenger å gjøre det selv, men heller kan korrigere kun det som er feil).

Det kan når som helst skje at denne prosessen stopper, enten fordi brukeren går lei og lukker vinduet/fanen, fordi strømmen eller nettet går, eller andre årsaker man ikke har tenkt på. Det går derfor jevnlig et timeout-script (kalt process-purchases) som tar ansvar for å rydde opp gamle ordre og forsøke å kansellere dem. Mer om dette under.

Detaljert informasjon om API-et fra frontendens side finnes i Billig frontend-API. Stripes intro til Checkout kan leses her, mens fullstendig API-dokumentasjon finnes på https://stripe.com/docs/api.

Intern implementasjon

Implementasjonen består av én modul, Billig::Stripe, og fire scripts: callback.pl, cancel.pl, pay.pl og process-purchases.pl. Billig::Stripe definerer funksjoner for å gjøre diverse kall mot Stripe, samt signering og verifisering av URL-parametre.

pay.pl

pay.pl er startpunktet for et kjøps reise mot Stripe; frontenden POST-er hit. Scriptet validerer kjøpsformen og forsøker så å reservere billettene vha. create_purchase(), som kaller SQL-funksjonen med samme navn (se Billig database), samt reserverer ev. seter (igjen en SQL-funksjon: assign_seat()). Med dette i boks opprettes en Checkout Session i Stripe. Denne kobles så til kjøpet ved at dens ID settes som purchase.payment_id. I samme slengen blir purchase.swedbank_initialized (som burde vært innløser-uavhengig og hett payment_initialized e.l.) satt. Om alt dette gikk fint, sender vi brukeren til Checkout Session-en sin URL og pay.pl exiter.

cancel.pl

cancel.pl er endepunktet brukeren sendes til dersom vedkommende velger å avbryte ordren på Checkout-siden (ved å trykke på en venstrepil i UI-et). Etter å ha validert at det ble kalt riktig og verifisert parametrene sine, forsøker scriptet først å kansellere ("expire") Checkout Session-en som kjøpet er knyttet til. Dette er viktig, ettersom vi aldri ønsker å havne i en situasjon der vi frigir billetter til et kjøp som faktisk har gått gjennom. Stripe garanterer at et "expire"-kall kun tillates om sessionen har statusen "open". Med andre ord: det er Stripe som til syvende og sist bestemmer om et kjøp er fullført eller ikke. Om dette går fint, sender vi brukeren tilbake til frontenden hvor vedkommende ev. kan velge å omstrukturere handlekurven. Skulle det vise seg at kjøpet allerede er gått gjennom, gir vi beskjed til brukeren om det og ber dem kontakte medlemskort@samfundet.no hvis de mener dette ikke skulle skjedd.

process-purchases.pl

process-purchases.pl (tidligere kjent som timeout-payex.pl) kjører som nevnt kontinuerlig som en cronjobb. Scriptets jobb er rimelig enkel: finn uferdige kjøp som har eksistert i mer enn 10 minutter og forsøk å kanseller dem slik at billettene kan kjøpes av noen andre. Skulle kjøpet vise seg å være vellykket, markeres det deretter (ved å sette paid) og vi sender billettene. I tillegg identifiserer scriptet nylig fullførte kjøp som ikke har ccvn-feltet satt, og gjør et kall til Stripe for å hente ut de fire siste sifferene i kundens kortnummer, som vi bruker til å verifisere vedkommende i supportsituasjoner.

callback.pl

callback.pl er det Stripe kaller et webhook endpoint: et endepunkt de sender events til. Disse eventene kommer som JSON-formaterte POST-forespørsler som scriptet så verifiserer. For oss er det kun snakk om to events: de selvforklarende "completed" og "expired". Sistnevnte har vi veldig liten nytte av, ettersom Stripes minstetid for at de skal kansellere et kjøp automatisk er 30 minutter, som er langt over levetiden vi ønsker at et kjøp skal ha. Med andre ord vil process-purchases alltid prøve å kansellere lenge før Stripe gjør det. Ettersom vi kan være sikre på at kjøpet var vellykket når callback.pl får "completed", setter vi like gjerne kjøpet som fullført (paid) der og sender ut epost, gitt at paid ikke allerede har blitt satt (f.eks. fra process-purchases). Dette løses ganske enkelt med en UPDATE…RETURNING-spørring; om ingenting returneres, er paid allerede satt og vi trenger ikke foreta oss noe. For "expired" kaller vi bare abort_purchase(), ettersom det ikke skader å avbryte et kjøp flere ganger.

Korrekthet

Når brukeren forlater pay.pl og havner hos Stripe, kan vi ikke gjøre annet enn å vente på at én av tre ting skal skje:

  1. callback.pl mottar et "completed"-event; kjøpet var vellykket.
  2. Brukeren avbryter kjøpet på Stripe-siden og blir sendt til cancel.pl; kjøpet blir forsøkt kansellert.
  3. Ingenting skjer innenfor vår spesifiserte tidsgrense; process-purchases plukker opp kjøpet og forsøker å kansellere det.

Som nevnt i cancel.pl-seksjonen betyr dette at det er Stripe som de facto bestemmer hvor mange billetter som er solgt til et arrangement; det er uhyre viktig at billetter ikke frigis før man har klart å avbryte kjøpet hos Stripe.

Versjonering

Stripes API er datoversjonert. Alle forespørsler gjort mot Stripe bruker automatisk API-versjonen vi har satt som standard i webfjeset, men man kan også sende med en Stripe-Version-header i forespørslene. Vi har valgt å gjøre sistnevnte for å unngå scenarioet hvor noen endrer API-versjon i UI-et med uhell og utløser Ragnarok. Dette gjør at man tvinges til å oppdatere kodebasen ved API-oppgradering.

Produkter og priser

For at man skal få selge noe som helst gjennom Stripe, krever de at man oppretter minst ett Product. I nåværende implementasjon har hver organisasjon i Billig sitt produkt i Stripe, f.eks. "Samfundet-billetter". Når vi oppretter en Checkout Session må vi legge ved ett Price-objekt (som igjen er knyttet til et Product), men dette kan heldigvis genereres inline.

TODOs

Lenker: Start, billig

Epost: itk@samfundet.no | Telefon: 992 15 925 | Sist endret: 2023-09-02 02:57 | Revisjon: 11 (historie, blame) | Totalt: 1888 kB | Rediger