Skip to content

Commit

Permalink
feat: support for subscription renewals
Browse files Browse the repository at this point in the history
  • Loading branch information
th0rgall committed Dec 4, 2023
1 parent 369cb98 commit 951b79b
Show file tree
Hide file tree
Showing 13 changed files with 250 additions and 12 deletions.
4 changes: 2 additions & 2 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ If another live testing webhook listener is already active, disable it first, to
2. Take over its events locally by running:

```
stripe listen --events customer.subscription.deleted,customer.subscription.updated,invoice.finalized,invoice.paid,payment_intent.processing --forward-to http://127.0.0.1:5001/wtmg-dev/europe-west1/stripeWebhooks
stripe listen --events customer.subscription.deleted,customer.subscription.updated,invoice.finalized,invoice.created,invoice.paid,payment_intent.processing --forward-to http://127.0.0.1:5001/wtmg-dev/europe-west1/stripeWebhooks
```

3. Verify that `/wtmg-dev/` in the URL above matches your current Firebase emulator project (did you run `firebase use wtmg-dev` before running Firebase emulators?). Also verify that the API emulator is active, with .env (`VITE_USE_API_EMULATOR=true`);
3. Verify that `/wtmg-dev/` in the URL above matches your current Firebase emulator project (did you run `firebase use wtmg-dev` before running Firebase emulators? Or are you using `/demo-test/`?). Also verify that the API emulator is active, with .env (`VITE_USE_API_EMULATOR=true`);

If you get an api-key-expired error, you must likely log in again. The authentication expires after 90 days.

Expand Down
48 changes: 48 additions & 0 deletions api/src/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,51 @@ exports.sendSubscriptionConfirmationEmail = (email, firstName, language) => {

return send(msg);
};

/**
* @typedef {Object} SubscriptionRenewalConfig
* @property {string} email
* @property {string} firstName
* @property {number} price expected to be an integer
* @property {string} renewalLink
* @property {string} language
*/

/**
* @param {SubscriptionRenewalConfig} config
* @returns
*/
exports.sendSubscriptionRenewalEmail = (config) => {
const { email, firstName, price, renewalLink, language } = config;
let templateId;
switch (language) {
case 'fr':
templateId = 'd-97e7ad7457d14f348833cb32a6143e33';
break;
case 'nl':
templateId = 'd-8efa4a0675c14098b9acd2d747e4db74';
break;
default:
templateId = 'd-77f6b26edb374b4197bdd30e2aafda03';
break;
}

const msg = {
to: email,
from: 'Welcome To My Garden <support@welcometomygarden.org>',
templateId,
dynamic_template_data: {
firstName,
price,
renewalLink
}
};

if (!canSendMail) {
console.warn(NO_API_KEY_WARNING);
console.info(JSON.stringify(msg));
return Promise.resolve();
}

return send(msg);
};
3 changes: 2 additions & 1 deletion api/src/subscriptions/constants.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ type StripeSubscriptionKeys = readonly [
'startDate',
'cancelAt',
'canceledAt',
'paymentProcessing'
'paymentProcessing',
'renewalInvoiceLink'
];
type StripeUpdateKey = StripeSubscriptionKeys[number];
type StripeUpdateKeysWithKeySuffix = `${StripeUpdateKey}Key`;
Expand Down
4 changes: 3 additions & 1 deletion api/src/subscriptions/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ const stripeSubscriptionSubKeys = [
'canceledAt',
// Whether the payment is approved, but still proccessing. To support immediately activating subscriptions for Sofort & other delayed notification payment methods.
// See paymentIntentProcessing
'paymentProcessing'
'paymentProcessing',
// Stripe-hosted invoice link
'renewalInvoiceLink'
];

// Keys that can be used in an .update command to a Firebase `users-private` doc
Expand Down
88 changes: 88 additions & 0 deletions api/src/subscriptions/stripeEventHandlers/invoiceCreated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// @ts-check
const functions = require('firebase-functions');
const stripe = require('../stripe');
const getFirebaseUserId = require('../getFirebaseUserId');
const { stripeSubscriptionKeys } = require('../constants');
const removeUndefined = require('../../util/removeUndefined');
const { db } = require('../../firebase');
const { sendSubscriptionRenewalEmail } = require('../../mail');

/**
* Handles the `invoice.created` event from Stripe
* @param {any} event
* @param {import('firebase-functions/v1').Response} res
*
*/
module.exports = async (event, res) => {
console.log('Handling invoice.created');
/** @type {import('stripe').Stripe.Invoice} */
const invoice = event.data.object;

const priceIdsObj = functions.config().stripe.price_ids;
const wtmgPriceIds = Object.values(priceIdsObj);

const price = invoice.lines.data[0]?.price;
const isWtmgSubscriptionInvoice = wtmgPriceIds.includes(price?.id || '');
if (invoice.billing_reason !== 'subscription_cycle' || !isWtmgSubscriptionInvoice) {
// Ignore invoices that were created for events not related
// to WTMG subscription renewals
return res.sendStatus(200);
}

const uid = await getFirebaseUserId(invoice.customer);

// Finalize the invoice
const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id);

const { renewalInvoiceLinkKey, latestInvoiceStatusKey } = stripeSubscriptionKeys;

if (!finalizedInvoice.hosted_invoice_url) {
const errorMsg = 'Could not correctly finalize the renewal invoice';
console.error(errorMsg);
res.status(500);
return res.send(errorMsg);
}

//
// Save the renewal invoice URL in Firebase
const privateUserProfileDocRef = db.doc(`users-private/${uid}`);
await privateUserProfileDocRef.update(
removeUndefined({
[renewalInvoiceLinkKey]: finalizedInvoice.hosted_invoice_url,
[latestInvoiceStatusKey]: finalizedInvoice.status
// startDate should not have changed
})
);

// Set the user's latest invoice state
// Get public & private data
const publicUserProfileDocRef = db.doc(`users/${uid}`);
const [publicUserProfileData, privateUserProfileData] = (
await Promise.all([publicUserProfileDocRef.get(), privateUserProfileDocRef.get()])
).map((s) => s.data());

if (
!(
publicUserProfileData &&
privateUserProfileData &&
finalizedInvoice.customer_email &&
finalizedInvoice.hosted_invoice_url &&
typeof price?.unit_amount === 'number'
)
) {
const errorMsg = 'Missing parameters to send a subscription renewal email';
res.status(500);
return res.send(errorMsg);
}

// Send renewal invoice email
await sendSubscriptionRenewalEmail({
email: finalizedInvoice.customer_email,
firstName: publicUserProfileData.firstName,
renewalLink: finalizedInvoice.hosted_invoice_url,
price: price.unit_amount / 100,
language: privateUserProfileData.communicationLanguage
});

return res.sendStatus(200);
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Handles the `invoice.finalized` event from Stripe
* @param {*} invoice
* @param {*} event
* @param {*} res
*/
module.exports = async (event, res) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const {
cancelAtKey,
canceledAtKey,
currentPeriodEndKey,
currentPeriodStartKey
currentPeriodStartKey,
latestInvoiceStatusKey
} = stripeSubscriptionKeys;

/**
Expand All @@ -33,7 +34,8 @@ module.exports = async (event, res) => {
[cancelAtKey]: subscription.cancel_at,
[canceledAtKey]: subscription.canceled_at,
[currentPeriodStartKey]: subscription.current_period_start,
[currentPeriodEndKey]: subscription.current_period_end
[currentPeriodEndKey]: subscription.current_period_end,
[latestInvoiceStatusKey]: subscription.latest_invoice.status
// startDate should not have changed
})
);
Expand Down
3 changes: 3 additions & 0 deletions api/src/subscriptions/webhookHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const invoicePaid = require('./stripeEventHandlers/invoicePaid');
const subscriptionUpdated = require('./stripeEventHandlers/subscriptionUpdated');
const subscriptionDeleted = require('./stripeEventHandlers/subscriptionDeleted');
const paymentIntentProcessing = require('./stripeEventHandlers/paymentIntentProcessing');
const invoiceCreated = require('./stripeEventHandlers/invoiceCreated');

// Imported in index
// https://firebase.google.com/docs/functions/http-events
Expand Down Expand Up @@ -40,6 +41,8 @@ exports.stripeWebhookHandler = async (req, res) => {
switch (event.type) {
case 'invoice.finalized':
return invoiceFinalized(event, res);
case 'invoice.created':
return invoiceCreated(event, res);
case 'invoice.finalization_failed':
// TODO ?
break;
Expand Down
10 changes: 9 additions & 1 deletion src/lib/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ type StripeSubscription = {
canceledAt: number;
/** Whether the last invoice payment is approved, but still processing */
paymentProcessing?: boolean;
/**
* The last (currently relevant)
* To be shown until 7 days after the currentPeriodStart, if the latest invoice status is not paid.
*/
renewalInvoiceLink: string | undefined;
};

type EmailPreferences = {
Expand Down Expand Up @@ -105,7 +110,10 @@ export class User implements UserPrivate, UserPublic {
this.emailVerified = user.emailVerified || false;
this.countryCode = user.countryCode || '';
this.garden = user.garden || null;
this.emailPreferences = user.emailPreferences || null;
this.emailPreferences = user.emailPreferences || {
newChat: true,
news: true
};
this.consentedAt = user.consentedAt || null;
this.communicationLanguage = user.communicationLanguage || '';
this.superfan = user.superfan || false;
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -993,7 +993,10 @@
}
},
"superfan": {
"loading-portal": "Loading portal..."
"loading-portal": "Loading portal...",
"valid": "Your membership is valid until {date}",
"just-ended": "Your membership has just ended.<br>We hope we can continue to count on your support! 💚",
"renew-btn-text": "Renew now"
},
"notify": {
"resend-error": "We couldn't resend an account verification email. Please contact {support}",
Expand Down
5 changes: 4 additions & 1 deletion src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -977,7 +977,10 @@
}
},
"superfan": {
"loading-portal": "Chargement du portail..."
"loading-portal": "Chargement du portail...",
"valid": "Votre adhésion est valable jusqu'au {date}",
"just-ended": "Votre adhésion vient de se terminer.<br>Nous espérons pouvoir continuer à compter sur votre soutien ! 💚",
"renew-btn-text": "Renouvelez maintenant"
},
"verify": {
"title": "Confirmez votre adresse électronique",
Expand Down
4 changes: 3 additions & 1 deletion src/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,9 @@
}
},
"superfan": {
"loading-portal": "Portaal aan het laden..."
"loading-portal": "Portaal aan het laden...",
"valid": "Jouw lidmaatschap is geldig tot {date}",
"just-ended": "Je lidmaatschap is net afgelopen.<br>We hopen dat we op jouw steun kunnen blijven rekenen! 💚"
},
"notify": {
"resend-error": "We konden geen accountverificatie e-mail sturen. Neem contact op met {support}",
Expand Down
80 changes: 79 additions & 1 deletion src/routes/account/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { goto } from '$lib/util/navigate';
import notify from '$lib/stores/notification';
import { updateMailPreferences } from '$lib/api/user';
Expand Down Expand Up @@ -69,6 +69,42 @@
hasResentEmail = false;
}
};
$: hasValidSubscription =
$user?.superfan &&
$user.stripeSubscription &&
$user.stripeSubscription.status === 'active' &&
$user.stripeSubscription.latestInvoiceStatus === 'paid';
const nowSeconds = () =>
// 1827649800 + 3600 * 24 * 7 + 3600;
new Date().valueOf() / 1000;
// nowSeconds just as a default value
$: sevenDayMarkSec =
($user?.stripeSubscription?.currentPeriodStart || nowSeconds()) + 3600 * 24 * 7;
// Note: until after 7 days, the current subscription can be renewed.
// After that, a new one has to be completed.
$: subscriptionJustEnded =
$user?.stripeSubscription &&
$user.stripeSubscription.latestInvoiceStatus !== 'paid' &&
// This is not the initial invoice
$user.stripeSubscription.currentPeriodStart !== $user.stripeSubscription.startDate &&
// We are 30 days until the last cycle ended
nowSeconds() < $user.stripeSubscription.currentPeriodStart + 3600 * 24 * 30;
$: hasOpenRenewalInvoice =
$user?.superfan &&
$user.stripeSubscription &&
$user.stripeSubscription.latestInvoiceStatus === 'open' &&
$user.stripeSubscription.renewalInvoiceLink &&
// The current second epoch is less than 7 days from the current (= new/unpaid) period start
nowSeconds() < sevenDayMarkSec;
$: promptForNewSubscription = subscriptionJustEnded && nowSeconds() >= sevenDayMarkSec;
const formatDate = (locale: string, date: Date) =>
new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date);
</script>

<svelte:head>
Expand Down Expand Up @@ -102,6 +138,35 @@
{$countryNames[$user.countryCode]}
</div>
</div>
{#if hasValidSubscription && $user.stripeSubscription}
<div class="superfan-validity">
<p>
✅ {$_('account.superfan.valid', {
values: {
date: formatDate(
$locale || 'en',
new Date($user.stripeSubscription?.currentPeriodEnd * 1000)
)
}
})}
</p>
</div>
{:else if subscriptionJustEnded}
<div class="superfan-validity invalid">
<p>{@html $_('account.superfan.just-ended')}</p>
<Button
xxsmall
uppercase
on:click={() => {
if (hasOpenRenewalInvoice) {
window.open($user?.stripeSubscription?.renewalInvoiceLink, '_blank');
} else if (promptForNewSubscription) {
window.location.href = `${routes.ABOUT_MEMBERSHIP}#pricing`;
}
}}>{$_('account.superfan.renew-btn-text')}</Button
>
</div>
{/if}
</section>
{#if !$user.emailVerified}
<section>
Expand Down Expand Up @@ -304,4 +369,17 @@
margin-top: 1rem;
display: block;
}
.superfan-validity {
margin-top: 1rem;
}
.superfan-validity.invalid {
display: flex;
gap: 2rem;
margin: 2rem 0 0 0;
text-align: left;
}
.superfan-validity.invalid button {
}
</style>

0 comments on commit 951b79b

Please sign in to comment.