Cancellation implemented

This commit is contained in:
olaven 2024-08-24 16:47:56 +02:00
parent 20eee29adc
commit 5b3f804391
7 changed files with 155 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import "jsr:@std/dotenv/load";
import { getHostName } from "../utils.ts";
import { getHostUrl } from "../utils.ts";
import { STATUS_CODE } from "$fresh/server.ts";
const config = {
@ -47,7 +47,7 @@ export const createAgreement = async function (email: string) {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"Idempotency-Key": email,
"Idempotency-Key": `${email}-${Date.now()}`,
...standardVippsHeaders,
},
body: JSON.stringify({
@ -60,8 +60,9 @@ export const createAgreement = async function (email: string) {
"currency": "NOK",
"type": "VARIABLE",
},
"merchantRedirectUrl": `${getHostName()}/donations-success`,
"merchantAgreementUrl": `${getHostName()}/donations`,
// email is used to identify the user in the success page
"merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`,
"merchantAgreementUrl": `${getHostUrl()}`,
"productName": "Månedlig støtte til NRSS",
}),
});
@ -70,6 +71,7 @@ export const createAgreement = async function (email: string) {
const body = response.json();
return body as unknown as {
vippsConfirmationUrl: string;
agreementId: string;
};
} else {
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);
}
};
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";
export const handler = async function (req: Request): Promise<Response> {
const email = new URLSearchParams(req.url.split("?")[1] || "").get("email");
const errorPage = `${getHostName()}/donations-error`;
const errorPage = `${getHostUrl()}/donations-error`;
if (!email) {
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);
}
// 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);
};

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 { 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 (
<div
className={"p-4 mx-auto max-w-screen-md text-center"}
@ -10,10 +54,36 @@ export default function () {
>
Avslutt støtte til NRSS
</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
placeholder={"ditt mobillnummer"}
type={"email"}
placeholder={"din@epost.no"}
required
name={"email"}
id={"email"}
>
</Input>
<Button
type={"submit"}
>
Avslutt
</Button>
</form>
)}
</div>
);
}

View File

@ -1,24 +1,37 @@
import { PageProps } from "$fresh/server.ts";
import { getAgreement } from "../lib/vipps/vipps.ts";
import { Handlers, PageProps } from "$fresh/server.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) {
// TODO: somehow get the agreement ID
// TODO: implement cancel page
const agreementId = "123";
const agreement = await getAgreement(agreementId);
if (agreement instanceof Error) {
console.error("Failed to get Vipps agreement", agreement);
return ctx.redirect("/donations-error");
const url = new URL(request.url);
const email = url.searchParams.get("email");
if (!email) {
console.error("Missing email in query params", request.url);
return Response.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<{
// TODO: confirm interface when you know it
agreement: {
status: string;
id: string;