Compare commits

...

3 Commits

Author SHA1 Message Date
Olav Sundfør 43477f0798
FIX: Existing agreement exists, but is out of sync (#41) 2025-05-26 14:31:55 +02:00
olaven 2109ae438f deno formatting 2025-04-30 16:18:05 +02:00
olaven 0ce01b97f1 Add a second promo text to the front.
NRSS does not have enough supporters to break even and thus costs me
quite a bit every month. However, it seems to have a stable and high
user base, most of whom presumably could pay. As earlier, I'd like to
avoid shoving the service behind a paywall - that feels totally wrong
for what this project is supposed to be. This PR introduces another,
shorter link to donations _before_ the episode description in the
hopes of attracting more paid users. Let's see if it works :)

The last time there was a promo text at the start of the description,
@augustskare correctly pointed out that this hides the original description
from many views in podcast players in #34. That's not nice. I've tried to
acommodate this by making the initial description much shorter than
the one I had then (which now comes at the end).
2025-04-30 16:16:33 +02:00
5 changed files with 92 additions and 52 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
test-script.ts
tmp tmp
# Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs # Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs

View File

@ -1,4 +1,4 @@
import { assertEquals, assertExists } from "$std/assert/mod.ts"; import { assertEquals, assertExists, assertLess } from "$std/assert/mod.ts";
import { testUtils } from "./test-utils.ts"; import { testUtils } from "./test-utils.ts";
import { rss } from "./rss.ts"; import { rss } from "./rss.ts";
import { forTestingOnly } from "./rss.ts"; import { forTestingOnly } from "./rss.ts";
@ -30,6 +30,20 @@ Deno.test("generated rss contains promo with link to donations page", () => {
const series = testUtils.generateSeries(); const series = testUtils.generateSeries();
const feed = rss.assembleFeed(series); const feed = rss.assembleFeed(series);
console.log(feed);
feed.includes("Vurder å støtte utviklingen via Vipps"); feed.includes("Vurder å støtte utviklingen via Vipps");
}); });
Deno.test("generated rss contains promo with link to donations page", () => {
const series = testUtils.generateSeries();
const feed = rss.assembleFeed(series);
const indexOfFirstPromotion = feed.indexOf(
"NRSS er avhengig av din Vipps-støtte",
);
const indexOfSecondPromotion = feed.indexOf("Vurder å støtte utviklingen");
assertLess(
indexOfFirstPromotion,
indexOfSecondPromotion,
"First promotion should come before the second",
);
});

View File

@ -27,16 +27,11 @@ function assembleFeed(series: Series): string {
tag("itunes:name", "NRK"), tag("itunes:name", "NRK"),
tag("itunes:email", "nrkpodcast@nrk.no"), tag("itunes:email", "nrkpodcast@nrk.no"),
]), ]),
tag( tag("description", series.subtitle || ""),
"description",
series.subtitle || "",
),
tag("ttl", "60"), //60 minutes tag("ttl", "60"), //60 minutes
...(series.imageUrl ...(series.imageUrl
? [ ? [
tag("itunes:image", "", [ tag("itunes:image", "", [["href", series.imageUrl]]),
["href", series.imageUrl],
]),
tag("image", [ tag("image", [
tag("url", series.imageUrl), tag("url", series.imageUrl),
tag("title", series.title), tag("title", series.title),
@ -58,10 +53,11 @@ function assembleFeed(series: Series): string {
} }
function descriptionWithDonationPromotion(description: string): string { function descriptionWithDonationPromotion(description: string): string {
const promotion = const firstPromotion = `NRSS er avhengig av din Vipps-støtte⬇`;
const secondPromotion =
`Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med omtrent det samme som prisen på en kaffekopp. Se mer på https://nrss.deno.dev/`; `Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med omtrent det samme som prisen på en kaffekopp. Se mer på https://nrss.deno.dev/`;
return `${description}\n\n${promotion}`; return `${firstPromotion}\n\n${description}\n\n${secondPromotion}`;
} }
function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
@ -76,10 +72,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
tag("pubDate", new Date(episode.date).toUTCString()), tag("pubDate", new Date(episode.date).toUTCString()),
tag("itunes:duration", episode.durationInSeconds.toString()), tag("itunes:duration", episode.durationInSeconds.toString()),
tag("podcast:chapters", "", [ tag("podcast:chapters", "", [
[ ["url", `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`],
"url",
`${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`,
],
["type", "application/json+chapters"], ["type", "application/json+chapters"],
]), ]),
tag("enclosure", "", [ tag("enclosure", "", [

View File

@ -7,8 +7,12 @@ import { STATUS_CODE } from "$fresh/server.ts";
const config = { const config = {
clientId: Deno.env.get("VIPPS_CLIENT_ID"), clientId: Deno.env.get("VIPPS_CLIENT_ID"),
clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"), clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"),
ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"), ocpApimSubscriptionKeyPrimary: Deno.env.get(
ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"), "VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY",
),
ocpApimSubscriptionKeySecondary: Deno.env.get(
"VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY",
),
msn: Deno.env.get("VIPPS_MSN"), msn: Deno.env.get("VIPPS_MSN"),
baseUrl: Deno.env.get("VIPPS_API_BASE_URL"), baseUrl: Deno.env.get("VIPPS_API_BASE_URL"),
}; };
@ -33,8 +37,8 @@ const getAccessToken = async function () {
method: "POST", method: "POST",
// @ts-ignore // @ts-ignore
headers: { headers: {
"client_id": config.clientId, client_id: config.clientId,
"client_secret": config.clientSecret, client_secret: config.clientSecret,
...standardVippsHeaders, ...standardVippsHeaders,
}, },
}); });
@ -56,23 +60,23 @@ export const createAgreement = async function (email: string) {
...standardVippsHeaders, ...standardVippsHeaders,
}, },
body: JSON.stringify({ body: JSON.stringify({
"interval": { interval: {
"unit": "MONTH", unit: "MONTH",
"count": 1, count: 1,
}, },
"initialCharge": { initialCharge: {
"amount": amount, amount: amount,
"description": "Initial charge", description: "Initial charge",
"transactionType": "DIRECT_CAPTURE", transactionType: "DIRECT_CAPTURE",
}, },
"pricing": { pricing: {
"amount": amount, amount: amount,
"currency": "NOK", currency: "NOK",
}, },
// email is used to identify the user in the success page // email is used to identify the user in the success page
"merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`, merchantRedirectUrl: `${getHostUrl()}/donations-success?email=${email}`,
"merchantAgreementUrl": `${getHostUrl()}`, merchantAgreementUrl: `${getHostUrl()}`,
"productName": "Månedlig støtte til NRSS", productName: "Månedlig støtte til NRSS",
}), }),
}); });
@ -89,15 +93,23 @@ export const createAgreement = async function (email: string) {
} }
}; };
export const getAgreement = async function (agreementId: string) { export const getAgreement = async function (agreementId: string): Promise<
| {
status: "PENDING" | "ACTIVE" | "STOPPED" | "EXPIRED";
}
| Error
> {
const token = await getAccessToken(); const token = await getAccessToken();
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { const response = await fetch(
// @ts-ignore `${config.baseUrl}/recurring/v3/agreements/${agreementId}`,
headers: { {
authorization: `Bearer ${token}`, // @ts-ignore
...standardVippsHeaders, headers: {
authorization: `Bearer ${token}`,
...standardVippsHeaders,
},
}, },
}); );
if (response.status === STATUS_CODE.OK) { if (response.status === STATUS_CODE.OK) {
return response.json(); return response.json();
@ -110,18 +122,21 @@ export const getAgreement = async function (agreementId: string) {
export const cancelAgreement = async function (agreementId: string) { export const cancelAgreement = async function (agreementId: string) {
const token = await getAccessToken(); const token = await getAccessToken();
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { const response = await fetch(
method: "PATCH", `${config.baseUrl}/recurring/v3/agreements/${agreementId}`,
// @ts-ignore {
headers: { method: "PATCH",
authorization: `Bearer ${token}`, // @ts-ignore
"Idempotency-Key": `${agreementId}-${Date.now()}`, headers: {
...standardVippsHeaders, authorization: `Bearer ${token}`,
"Idempotency-Key": `${agreementId}-${Date.now()}`,
...standardVippsHeaders,
},
body: JSON.stringify({
status: "STOPPED",
}),
}, },
body: JSON.stringify({ );
"status": "STOPPED",
}),
});
if (response.status === STATUS_CODE.NoContent) { if (response.status === STATUS_CODE.NoContent) {
return true; return true;

View File

@ -1,6 +1,7 @@
import { getHostUrl, validateEmail } from "../../../lib/utils.ts"; import { getHostUrl, validateEmail } from "../../../lib/utils.ts";
import { storage } from "../../../lib/storage.ts"; import { storage } from "../../../lib/storage.ts";
import { createAgreement } from "../../../lib/vipps/vipps.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts";
import { getAgreement } 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");
@ -21,11 +22,27 @@ export const handler = async function (req: Request): Promise<Response> {
const hasActiveAgreement = existingAgreement && const hasActiveAgreement = existingAgreement &&
existingAgreement.validAt !== null && existingAgreement.validAt !== null &&
existingAgreement.revokedAt === null; existingAgreement.revokedAt === null;
const agreementInVipps = existingAgreement && (await getAgreement(existingAgreement.agreementId));
if ( if (
agreementInVipps &&
!(agreementInVipps instanceof Error) &&
agreementInVipps.status !== "ACTIVE" &&
hasActiveAgreement hasActiveAgreement
) { ) {
console.error("Agreement already exists", email); // Somehow the Vipps agreement is not active in our system anymore.
return Response.redirect(errorPage); // We should reflect the new status in our system.
await storage.writeVippsAgreement({
...existingAgreement,
revokedAt: new Date(),
});
} else {
// i.e. the agreement in our system was in sync with Vipps.
if (hasActiveAgreement) {
console.error("Agreement already exists", email);
return Response.redirect(errorPage);
}
} }
const agreement = await createAgreement(email); const agreement = await createAgreement(email);