From 0ce01b97f11772250e0b42c9532de24d634dd3b3 Mon Sep 17 00:00:00 2001 From: olaven Date: Wed, 30 Apr 2025 16:07:21 +0200 Subject: [PATCH 1/4] 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). --- lib/rss.test.ts | 18 ++++++++++++++++-- lib/rss.ts | 40 +++++++++++++++++----------------------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/lib/rss.test.ts b/lib/rss.test.ts index fead2fb..b7afaba 100644 --- a/lib/rss.test.ts +++ b/lib/rss.test.ts @@ -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 { rss } 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 feed = rss.assembleFeed(series); - console.log(feed); 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" + ); +}); diff --git a/lib/rss.ts b/lib/rss.ts index 3124649..20eb1cc 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -27,24 +27,21 @@ function assembleFeed(series: Series): string { tag("itunes:name", "NRK"), tag("itunes:email", "nrkpodcast@nrk.no"), ]), - tag( - "description", - series.subtitle || "", - ), + tag("description", series.subtitle || ""), tag("ttl", "60"), //60 minutes ...(series.imageUrl ? [ - tag("itunes:image", "", [ - ["href", series.imageUrl], - ]), - tag("image", [ - tag("url", series.imageUrl), - tag("title", series.title), - tag("link", series.link), - ]), - ] + tag("itunes:image", "", [["href", series.imageUrl]]), + tag("image", [ + tag("url", series.imageUrl), + tag("title", series.title), + tag("link", series.link), + ]), + ] : []), - ...series.episodes.map((episode) => assembleEpisode(episode, series.id)), + ...series.episodes.map((episode) => + assembleEpisode(episode, series.id) + ), ]), ], [ @@ -52,16 +49,16 @@ function assembleFeed(series: Series): string { ["xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"], ["xmlns:content", "http://purl.org/rss/1.0/modules/content/"], ["xmlns:podcast", "https://podcastindex.org/namespace/1.0"], - ], - ), + ] + ) ); } function descriptionWithDonationPromotion(description: string): string { - const promotion = - `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/`; + 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/`; - return `${description}\n\n${promotion}`; + return `${firstPromotion}\n\n${description}\n\n${secondPromotion}`; } function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { @@ -76,10 +73,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { tag("pubDate", new Date(episode.date).toUTCString()), tag("itunes:duration", episode.durationInSeconds.toString()), tag("podcast:chapters", "", [ - [ - "url", - `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`, - ], + ["url", `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`], ["type", "application/json+chapters"], ]), tag("enclosure", "", [ -- 2.40.1 From 2109ae438f97307d9eb47b84ff022161350140e9 Mon Sep 17 00:00:00 2001 From: olaven Date: Wed, 30 Apr 2025 16:18:05 +0200 Subject: [PATCH 2/4] deno formatting --- lib/rss.test.ts | 4 ++-- lib/rss.ts | 25 ++++++++++++------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/lib/rss.test.ts b/lib/rss.test.ts index b7afaba..2247bf1 100644 --- a/lib/rss.test.ts +++ b/lib/rss.test.ts @@ -38,12 +38,12 @@ Deno.test("generated rss contains promo with link to donations page", () => { const feed = rss.assembleFeed(series); const indexOfFirstPromotion = feed.indexOf( - "NRSS er avhengig av din Vipps-støtte" + "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" + "First promotion should come before the second", ); }); diff --git a/lib/rss.ts b/lib/rss.ts index 20eb1cc..812ac11 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -31,17 +31,15 @@ function assembleFeed(series: Series): string { tag("ttl", "60"), //60 minutes ...(series.imageUrl ? [ - tag("itunes:image", "", [["href", series.imageUrl]]), - tag("image", [ - tag("url", series.imageUrl), - tag("title", series.title), - tag("link", series.link), - ]), - ] + tag("itunes:image", "", [["href", series.imageUrl]]), + tag("image", [ + tag("url", series.imageUrl), + tag("title", series.title), + tag("link", series.link), + ]), + ] : []), - ...series.episodes.map((episode) => - assembleEpisode(episode, series.id) - ), + ...series.episodes.map((episode) => assembleEpisode(episode, series.id)), ]), ], [ @@ -49,14 +47,15 @@ function assembleFeed(series: Series): string { ["xmlns:itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd"], ["xmlns:content", "http://purl.org/rss/1.0/modules/content/"], ["xmlns:podcast", "https://podcastindex.org/namespace/1.0"], - ] - ) + ], + ), ); } function descriptionWithDonationPromotion(description: string): string { 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/`; + 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/`; return `${firstPromotion}\n\n${description}\n\n${secondPromotion}`; } -- 2.40.1 From 43477f07987ace6ae7c5b1dc7f115395e51d66b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20Sundf=C3=B8r?= Date: Mon, 26 May 2025 14:31:55 +0200 Subject: [PATCH 3/4] FIX: Existing agreement exists, but is out of sync (#41) --- .gitignore | 1 + lib/vipps/vipps.ts | 85 ++++++++++++++++------------ routes/api/trigger-donation/vipps.ts | 21 ++++++- 3 files changed, 70 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index a49d6cb..9c1cbf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +test-script.ts tmp # Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index 33cdeb3..79f03da 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -7,8 +7,12 @@ import { STATUS_CODE } from "$fresh/server.ts"; const config = { clientId: Deno.env.get("VIPPS_CLIENT_ID"), clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"), - ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"), - ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"), + ocpApimSubscriptionKeyPrimary: Deno.env.get( + "VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY", + ), + ocpApimSubscriptionKeySecondary: Deno.env.get( + "VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY", + ), msn: Deno.env.get("VIPPS_MSN"), baseUrl: Deno.env.get("VIPPS_API_BASE_URL"), }; @@ -33,8 +37,8 @@ const getAccessToken = async function () { method: "POST", // @ts-ignore headers: { - "client_id": config.clientId, - "client_secret": config.clientSecret, + client_id: config.clientId, + client_secret: config.clientSecret, ...standardVippsHeaders, }, }); @@ -56,23 +60,23 @@ export const createAgreement = async function (email: string) { ...standardVippsHeaders, }, body: JSON.stringify({ - "interval": { - "unit": "MONTH", - "count": 1, + interval: { + unit: "MONTH", + count: 1, }, - "initialCharge": { - "amount": amount, - "description": "Initial charge", - "transactionType": "DIRECT_CAPTURE", + initialCharge: { + amount: amount, + description: "Initial charge", + transactionType: "DIRECT_CAPTURE", }, - "pricing": { - "amount": amount, - "currency": "NOK", + pricing: { + amount: amount, + currency: "NOK", }, // 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", + merchantRedirectUrl: `${getHostUrl()}/donations-success?email=${email}`, + merchantAgreementUrl: `${getHostUrl()}`, + 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 response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { - // @ts-ignore - headers: { - authorization: `Bearer ${token}`, - ...standardVippsHeaders, + const response = await fetch( + `${config.baseUrl}/recurring/v3/agreements/${agreementId}`, + { + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + ...standardVippsHeaders, + }, }, - }); + ); if (response.status === STATUS_CODE.OK) { return response.json(); @@ -110,18 +122,21 @@ export const getAgreement = async function (agreementId: string) { export const cancelAgreement = async function (agreementId: string) { const token = await getAccessToken(); - const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { - method: "PATCH", - // @ts-ignore - headers: { - authorization: `Bearer ${token}`, - "Idempotency-Key": `${agreementId}-${Date.now()}`, - ...standardVippsHeaders, + const response = await fetch( + `${config.baseUrl}/recurring/v3/agreements/${agreementId}`, + { + method: "PATCH", + // @ts-ignore + headers: { + 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) { return true; diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 7d20900..236c8cc 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -1,6 +1,7 @@ import { getHostUrl, validateEmail } from "../../../lib/utils.ts"; import { storage } from "../../../lib/storage.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts"; +import { getAgreement } from "../../../lib/vipps/vipps.ts"; export const handler = async function (req: Request): Promise { const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); @@ -21,11 +22,27 @@ export const handler = async function (req: Request): Promise { const hasActiveAgreement = existingAgreement && existingAgreement.validAt !== null && existingAgreement.revokedAt === null; + + const agreementInVipps = existingAgreement && (await getAgreement(existingAgreement.agreementId)); + if ( + agreementInVipps && + !(agreementInVipps instanceof Error) && + agreementInVipps.status !== "ACTIVE" && hasActiveAgreement ) { - console.error("Agreement already exists", email); - return Response.redirect(errorPage); + // Somehow the Vipps agreement is not active in our system anymore. + // 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); -- 2.40.1 From 89ad059938952c8e8a242e685a938271b1e55ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olav=20Sundf=C3=B8r?= Date: Mon, 26 May 2025 14:32:32 +0200 Subject: [PATCH 4/4] Revert "FIX: Existing agreement exists, but is out of sync (#41)" This reverts commit 43477f07987ace6ae7c5b1dc7f115395e51d66b1. --- .gitignore | 1 - lib/vipps/vipps.ts | 85 ++++++++++++---------------- routes/api/trigger-donation/vipps.ts | 21 +------ 3 files changed, 37 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 9c1cbf1..a49d6cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -test-script.ts tmp # Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts index 79f03da..33cdeb3 100644 --- a/lib/vipps/vipps.ts +++ b/lib/vipps/vipps.ts @@ -7,12 +7,8 @@ import { STATUS_CODE } from "$fresh/server.ts"; const config = { clientId: Deno.env.get("VIPPS_CLIENT_ID"), clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"), - ocpApimSubscriptionKeyPrimary: Deno.env.get( - "VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY", - ), - ocpApimSubscriptionKeySecondary: Deno.env.get( - "VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY", - ), + ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"), + ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"), msn: Deno.env.get("VIPPS_MSN"), baseUrl: Deno.env.get("VIPPS_API_BASE_URL"), }; @@ -37,8 +33,8 @@ const getAccessToken = async function () { method: "POST", // @ts-ignore headers: { - client_id: config.clientId, - client_secret: config.clientSecret, + "client_id": config.clientId, + "client_secret": config.clientSecret, ...standardVippsHeaders, }, }); @@ -60,23 +56,23 @@ export const createAgreement = async function (email: string) { ...standardVippsHeaders, }, body: JSON.stringify({ - interval: { - unit: "MONTH", - count: 1, + "interval": { + "unit": "MONTH", + "count": 1, }, - initialCharge: { - amount: amount, - description: "Initial charge", - transactionType: "DIRECT_CAPTURE", + "initialCharge": { + "amount": amount, + "description": "Initial charge", + "transactionType": "DIRECT_CAPTURE", }, - pricing: { - amount: amount, - currency: "NOK", + "pricing": { + "amount": amount, + "currency": "NOK", }, // 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", + "merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`, + "merchantAgreementUrl": `${getHostUrl()}`, + "productName": "Månedlig støtte til NRSS", }), }); @@ -93,23 +89,15 @@ export const createAgreement = async function (email: string) { } }; -export const getAgreement = async function (agreementId: string): Promise< - | { - status: "PENDING" | "ACTIVE" | "STOPPED" | "EXPIRED"; - } - | Error -> { +export const getAgreement = async function (agreementId: string) { const token = await getAccessToken(); - const response = await fetch( - `${config.baseUrl}/recurring/v3/agreements/${agreementId}`, - { - // @ts-ignore - headers: { - authorization: `Bearer ${token}`, - ...standardVippsHeaders, - }, + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + ...standardVippsHeaders, }, - ); + }); if (response.status === STATUS_CODE.OK) { return response.json(); @@ -122,21 +110,18 @@ export const getAgreement = async function (agreementId: string): Promise< export const cancelAgreement = async function (agreementId: string) { const token = await getAccessToken(); - const response = await fetch( - `${config.baseUrl}/recurring/v3/agreements/${agreementId}`, - { - method: "PATCH", - // @ts-ignore - headers: { - authorization: `Bearer ${token}`, - "Idempotency-Key": `${agreementId}-${Date.now()}`, - ...standardVippsHeaders, - }, - body: JSON.stringify({ - status: "STOPPED", - }), + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + method: "PATCH", + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + "Idempotency-Key": `${agreementId}-${Date.now()}`, + ...standardVippsHeaders, }, - ); + body: JSON.stringify({ + "status": "STOPPED", + }), + }); if (response.status === STATUS_CODE.NoContent) { return true; diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts index 236c8cc..7d20900 100644 --- a/routes/api/trigger-donation/vipps.ts +++ b/routes/api/trigger-donation/vipps.ts @@ -1,7 +1,6 @@ import { getHostUrl, validateEmail } from "../../../lib/utils.ts"; import { storage } from "../../../lib/storage.ts"; import { createAgreement } from "../../../lib/vipps/vipps.ts"; -import { getAgreement } from "../../../lib/vipps/vipps.ts"; export const handler = async function (req: Request): Promise { const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); @@ -22,27 +21,11 @@ export const handler = async function (req: Request): Promise { const hasActiveAgreement = existingAgreement && existingAgreement.validAt !== null && existingAgreement.revokedAt === null; - - const agreementInVipps = existingAgreement && (await getAgreement(existingAgreement.agreementId)); - if ( - agreementInVipps && - !(agreementInVipps instanceof Error) && - agreementInVipps.status !== "ACTIVE" && hasActiveAgreement ) { - // Somehow the Vipps agreement is not active in our system anymore. - // 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); - } + console.error("Agreement already exists", email); + return Response.redirect(errorPage); } const agreement = await createAgreement(email); -- 2.40.1