diff --git a/api/README.md b/api/README.md index b0551e48..70ff9653 100644 --- a/api/README.md +++ b/api/README.md @@ -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. diff --git a/api/src/mail.js b/api/src/mail.js index ca321c18..574a05fc 100644 --- a/api/src/mail.js +++ b/api/src/mail.js @@ -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 ', + 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); +}; diff --git a/api/src/subscriptions/constants.d.ts b/api/src/subscriptions/constants.d.ts index cc361352..8d629d8e 100644 --- a/api/src/subscriptions/constants.d.ts +++ b/api/src/subscriptions/constants.d.ts @@ -8,7 +8,8 @@ type StripeSubscriptionKeys = readonly [ 'startDate', 'cancelAt', 'canceledAt', - 'paymentProcessing' + 'paymentProcessing', + 'renewalInvoiceLink' ]; type StripeUpdateKey = StripeSubscriptionKeys[number]; type StripeUpdateKeysWithKeySuffix = `${StripeUpdateKey}Key`; diff --git a/api/src/subscriptions/constants.js b/api/src/subscriptions/constants.js index 728c0ef2..48f17d42 100644 --- a/api/src/subscriptions/constants.js +++ b/api/src/subscriptions/constants.js @@ -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 diff --git a/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js b/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js new file mode 100644 index 00000000..aff498c1 --- /dev/null +++ b/api/src/subscriptions/stripeEventHandlers/invoiceCreated.js @@ -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); +}; diff --git a/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js b/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js index 9aa1b628..61222a32 100644 --- a/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js +++ b/api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js @@ -1,6 +1,6 @@ /** * Handles the `invoice.finalized` event from Stripe - * @param {*} invoice + * @param {*} event * @param {*} res */ module.exports = async (event, res) => { diff --git a/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js b/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js index 8c7a3167..3468b568 100644 --- a/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js +++ b/api/src/subscriptions/stripeEventHandlers/subscriptionUpdated.js @@ -9,7 +9,8 @@ const { cancelAtKey, canceledAtKey, currentPeriodEndKey, - currentPeriodStartKey + currentPeriodStartKey, + latestInvoiceStatusKey } = stripeSubscriptionKeys; /** @@ -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 }) ); diff --git a/api/src/subscriptions/webhookHandler.js b/api/src/subscriptions/webhookHandler.js index dfdbb289..55136d66 100644 --- a/api/src/subscriptions/webhookHandler.js +++ b/api/src/subscriptions/webhookHandler.js @@ -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 @@ -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; diff --git a/src/lib/models/User.ts b/src/lib/models/User.ts index 3d418043..5adb0e98 100644 --- a/src/lib/models/User.ts +++ b/src/lib/models/User.ts @@ -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 = { @@ -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; diff --git a/src/locales/en.json b/src/locales/en.json index f5af336c..a0d04035 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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.
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}", diff --git a/src/locales/fr.json b/src/locales/fr.json index ef7fa9b9..7508216f 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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.
Nous espérons pouvoir continuer à compter sur votre soutien ! 💚", + "renew-btn-text": "Renouvelez maintenant" }, "verify": { "title": "Confirmez votre adresse électronique", diff --git a/src/locales/nl.json b/src/locales/nl.json index f85c4ce7..f6a7894a 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -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.
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}", diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index fa305bfb..e3276091 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -1,5 +1,5 @@ @@ -102,6 +138,35 @@ {$countryNames[$user.countryCode]} + {#if hasValidSubscription && $user.stripeSubscription} +
+

+ ✅ {$_('account.superfan.valid', { + values: { + date: formatDate( + $locale || 'en', + new Date($user.stripeSubscription?.currentPeriodEnd * 1000) + ) + } + })} +

+
+ {:else if subscriptionJustEnded} +
+

{@html $_('account.superfan.just-ended')}

+ +
+ {/if} {#if !$user.emailVerified}
@@ -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 { + }