Vipps Donations #33

Merged
olaven merged 18 commits from vipps-demo into main 2024-08-24 16:55:08 +00:00
7 changed files with 155 additions and 36 deletions
Showing only changes of commit 5b3f804391 - Show all commits

View File

@ -1,5 +1,5 @@
import { declaration, serialize, Tag, tag } from "serialize-xml"; import { declaration, serialize, Tag, tag } from "serialize-xml";
import { getHostName } from "./utils.ts"; import { getHostUrl } from "./utils.ts";
import { Episode, Series } from "./storage.ts"; import { Episode, Series } from "./storage.ts";
function assembleFeed(series: Series): string { function assembleFeed(series: Series): string {
@ -71,7 +71,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
tag("podcast:chapters", "", [ tag("podcast:chapters", "", [
[ [
"url", "url",
`${getHostName()}/api/feeds/${seriesId}/${episode.id}/chapters`, `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`,
], ],
["type", "application/json+chapters"], ["type", "application/json+chapters"],
]), ]),

View File

@ -19,10 +19,12 @@ export type Series = {
}; };
export type VippsAgreement = { export type VippsAgreement = {
// the user identifier / email
id: string; id: string;
// the agreement id from Vipps
agreementId: string; agreementId: string;
userEmail: string;
createdAt: Date; createdAt: Date;
validAt: Date | null;
revokedAt: Date | null; revokedAt: Date | null;
}; };

View File

@ -1,20 +1,18 @@
import { STATUS_CODE } from "$fresh/server.ts"; import { STATUS_CODE } from "$fresh/server.ts";
export function getHostName() { export function getHostUrl() {
const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID"); const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID");
const tunnelUrl = Deno.env.get("TUNNEL_URL");
if (deploymentId) { if (deploymentId) {
return `https://nrss-${deploymentId}.deno.dev`; return `https://nrss-${deploymentId}.deno.dev`;
} else { } else if (tunnelUrl) {
const proxyUrl = Deno.env.get("PROXY_URL"); console.debug(`Using tunnel URL: ${tunnelUrl}`);
if (proxyUrl) { return tunnelUrl;
console.debug(`Using proxy URL ${proxyUrl}`);
return proxyUrl;
} else { } else {
console.debug(`Assuming localhost`); console.debug(`Assuming localhost`);
return "http://localhost:8000"; return "http://localhost:8000";
} }
} }
}
type EnumValues<T> = T[keyof T]; type EnumValues<T> = T[keyof T];
type Status = EnumValues<typeof STATUS_CODE>; type Status = EnumValues<typeof STATUS_CODE>;

View File

@ -1,5 +1,5 @@
import "jsr:@std/dotenv/load"; import "jsr:@std/dotenv/load";
import { getHostName } from "../utils.ts"; import { getHostUrl } from "../utils.ts";
import { STATUS_CODE } from "$fresh/server.ts"; import { STATUS_CODE } from "$fresh/server.ts";
const config = { const config = {
@ -47,7 +47,7 @@ export const createAgreement = async function (email: string) {
method: "POST", method: "POST",
headers: { headers: {
authorization: `Bearer ${token}`, authorization: `Bearer ${token}`,
"Idempotency-Key": email, "Idempotency-Key": `${email}-${Date.now()}`,
...standardVippsHeaders, ...standardVippsHeaders,
}, },
body: JSON.stringify({ body: JSON.stringify({
@ -60,8 +60,9 @@ export const createAgreement = async function (email: string) {
"currency": "NOK", "currency": "NOK",
"type": "VARIABLE", "type": "VARIABLE",
}, },
"merchantRedirectUrl": `${getHostName()}/donations-success`, // email is used to identify the user in the success page
"merchantAgreementUrl": `${getHostName()}/donations`, "merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`,
"merchantAgreementUrl": `${getHostUrl()}`,
"productName": "Månedlig støtte til NRSS", "productName": "Månedlig støtte til NRSS",
}), }),
}); });
@ -70,6 +71,7 @@ export const createAgreement = async function (email: string) {
const body = response.json(); const body = response.json();
return body as unknown as { return body as unknown as {
vippsConfirmationUrl: string; vippsConfirmationUrl: string;
agreementId: string;
}; };
} else { } else {
const errorMessage = `Failed to create Vipps agreement ${response.status} ${await response.text()}`; const errorMessage = `Failed to create Vipps agreement ${response.status} ${await response.text()}`;
@ -95,3 +97,26 @@ export const getAgreement = async function (agreementId: string) {
return new Error(errorMessage); return new Error(errorMessage);
} }
}; };
export const cancelAgreement = async function (agreementId: string) {
const token = await getAccessToken();
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, {
method: "PATCH",
headers: {
authorization: `Bearer ${token}`,
"Idempotency-Key": `${agreementId}-${Date.now()}`,
...standardVippsHeaders,
},
body: JSON.stringify({
"status": "STOPPED",
}),
});
if (response.status === STATUS_CODE.NoContent) {
return true;
} else {
const errorMessage = `Failed to cancel Vipps agreement ${response.status} ${await response.text()}`;
console.error(errorMessage);
return new Error(errorMessage);
}
};

View File

@ -1,9 +1,10 @@
import { getHostName, validateEmail } from "../../../lib/utils.ts"; import { getHostUrl, validateEmail } from "../../../lib/utils.ts";
import { storage } from "../../../lib/storage.ts";
import { createAgreement } from "../../../lib/vipps/vipps.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts";
export const handler = async function (req: Request): Promise<Response> { export const handler = async function (req: Request): Promise<Response> {
const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); const email = new URLSearchParams(req.url.split("?")[1] || "").get("email");
const errorPage = `${getHostName()}/donations-error`; const errorPage = `${getHostUrl()}/donations-error`;
if (!email) { if (!email) {
console.error("Missing email in query params", req.url); console.error("Missing email in query params", req.url);
@ -22,5 +23,15 @@ export const handler = async function (req: Request): Promise<Response> {
return Response.redirect(errorPage); return Response.redirect(errorPage);
} }
// associate the agreement with the user here
// so it can be updated in the success page
await storage.writeVippsAgreement({
id: email,
agreementId: agreement.agreementId,
createdAt: new Date(),
validAt: null,
revokedAt: null,
});
return Response.redirect(agreement.vippsConfirmationUrl); return Response.redirect(agreement.vippsConfirmationUrl);
}; };

View File

@ -1,6 +1,50 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { Button } from "../components/Button.tsx";
import { Input } from "../components/Input.tsx"; import { Input } from "../components/Input.tsx";
import { storage } from "../lib/storage.ts";
import * as vipps from "../lib/vipps/vipps.ts";
import { validateEmail } from "../lib/utils.ts";
export default function () { type Props = {
email: string;
error: string | null;
cancelled: boolean;
};
export const handler: Handlers<Props> = {
async GET(request, ctx) {
const url = new URL(request.url);
const email = url.searchParams.get("email");
if (!email) {
return ctx.render({ email: null, error: null, cancelled: false });
}
if (!validateEmail(email)) {
console.error("Ugyldig e-post server-side", email);
return ctx.render({ email: null, error: "Ugyldig e-post", cancelled: false });
}
const agreement = await storage.readVippsAgreement({ id: email });
if (!agreement) {
console.error("Mangler avtale for e-post", email);
return ctx.render({ email: null, error: `Ingen abonnement funnet for "${email}"`, cancelled: false });
}
const vippsResponse = await vipps.cancelAgreement(agreement.agreementId);
if (vippsResponse instanceof Error) {
return ctx.render({ email, error: "Kunne ikke kansellere Vipps-abonnement", cancelled: false });
}
await storage.writeVippsAgreement({
...agreement,
revokedAt: new Date(),
});
return ctx.render({ email, error: null, cancelled: true });
},
};
export default function ({ data }: PageProps<Props>) {
return ( return (
<div <div
className={"p-4 mx-auto max-w-screen-md text-center"} className={"p-4 mx-auto max-w-screen-md text-center"}
@ -10,10 +54,36 @@ export default function () {
> >
Avslutt støtte til NRSS Avslutt støtte til NRSS
</h1> </h1>
{data.error && (
<p className={"text-red-500"}>
En feil oppsto: "{data.error}". Ta gjerne{" "}
<a className="text-blue-600 underline" href="mailto:olav@sundfoer.com">
kontakt mail
</a>.
</p>
)}
{data.cancelled && (
<p>
Støtten er avsluttet. Tusen takk for at du har støttet NRSS!
</p>
)}
{!data.cancelled && (
<form>
<Input <Input
placeholder={"ditt mobillnummer"} type={"email"}
placeholder={"din@epost.no"}
required
name={"email"}
id={"email"}
> >
</Input> </Input>
<Button
type={"submit"}
>
Avslutt
</Button>
</form>
)}
</div> </div>
); );
} }

View File

@ -1,24 +1,37 @@
import { PageProps } from "$fresh/server.ts"; import { Handlers, PageProps } from "$fresh/server.ts";
import { getAgreement } from "../lib/vipps/vipps.ts"; import { storage } from "../lib/storage.ts";
import { validateEmail } from "../lib/utils.ts";
export const handler: Handlers<Props> = { export const handler: Handlers = {
async GET(request, ctx) { async GET(request, ctx) {
// TODO: somehow get the agreement ID const url = new URL(request.url);
// TODO: implement cancel page const email = url.searchParams.get("email");
if (!email) {
const agreementId = "123"; console.error("Missing email in query params", request.url);
const agreement = await getAgreement(agreementId); return Response.redirect("/donations-error");
if (agreement instanceof Error) {
console.error("Failed to get Vipps agreement", agreement);
return ctx.redirect("/donations-error");
} }
return ctx.render({ agreement }); if (!validateEmail(email)) {
console.error("Invalid email server side", email);
return Response.redirect("/donations-error");
}
const agreement = await storage.readVippsAgreement({ id: email });
if (!agreement) {
console.error("Missing agreement for email", email);
return Response.redirect("/donations-error");
}
const validatedAgreement = await storage.writeVippsAgreement({
...agreement,
validAt: new Date(),
});
return ctx.render({ agreement: validatedAgreement });
}, },
}; };
export default function ({ data }: PageProps<{ export default function ({ data }: PageProps<{
// TODO: confirm interface when you know it
agreement: { agreement: {
status: string; status: string;
id: string; id: string;