Vipps Donations #33
|
|
@ -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"],
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
12
lib/utils.ts
12
lib/utils.ts
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 på mail
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.cancelled && (
|
||||||
|
<p>
|
||||||
|
Støtten er nå 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue