Skip to content

Commit

Permalink
Merge pull request #339 from WelcometoMyGarden/fix/chunked-gardens
Browse files Browse the repository at this point in the history
Gardens via the REST API
  • Loading branch information
th0rgall authored Mar 14, 2024
2 parents ae12198 + 89de6c8 commit 89a6cae
Show file tree
Hide file tree
Showing 16 changed files with 371 additions and 74 deletions.
8 changes: 2 additions & 6 deletions api/src/subscriptions/stripeEventHandlers/invoiceCreated.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
// @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');

const { isWTMGInvoice } = require('./util');
/**
* Handles the `invoice.created` event from Stripe.
* Only handles WTMG subscription renewal invoices, ignores other invoices.
Expand All @@ -20,13 +19,10 @@ module.exports = async (event, res) => {
/** @type {import('stripe').Stripe.Invoice} */
const invoice = event.data.object;

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

// NOTE: we can only rely on this price ID being accurate because we only look for subscription_cycle invoices
// If we were to handle subscription_update invoices here, the price ID may be different, see [one-off-invoice]
const isWtmgSubscriptionInvoice = await isWTMGInvoice(invoice);
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
Expand Down
8 changes: 8 additions & 0 deletions api/src/subscriptions/stripeEventHandlers/invoiceFinalized.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
// @ts-check

const { isWTMGInvoice } = require('./util');

/**
* Handles the `invoice.finalized` event from Stripe
* @param {*} event
Expand All @@ -6,6 +10,10 @@
module.exports = async (event, res) => {
console.log('Handling invoice.finalized');
const invoice = event.data.object;
if (!(await isWTMGInvoice(invoice))) {
console.log('Ignoring non-WTMG invoice');
return res.sendStatus(200);
}
// TODO send email with the finalized invoice!?
// see "hosted_invoice_url" and "invoice_pdf"

Expand Down
5 changes: 5 additions & 0 deletions api/src/subscriptions/stripeEventHandlers/invoicePaid.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
} = require('../../mail');
const { stripeSubscriptionKeys } = require('../constants');
const { db } = require('../../firebase');
const { isWTMGInvoice } = require('./util');

const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys;

Expand All @@ -19,6 +20,10 @@ const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys;
module.exports = async (event, res) => {
console.log('Handling invoice.paid');
const invoice = event.data.object;
if (!(await isWTMGInvoice(invoice))) {
console.log('Ignoring non-WTMG invoice');
return res.sendStatus(200);
}
const uid = await getFirebaseUserId(invoice.customer);

// Set the user's latest invoice state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ const {
const { stripeSubscriptionKeys } = require('../constants');
const { db } = require('../../firebase');
const stripe = require('../stripe');
const { isWTMGInvoice } = require('./util');

const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys;

/**
*
* Inform Firebase of an approved, processing payment + send membership confirmation email
* Special case handlig of SOFORT, where we consider an "network approved" initiated payment
* Special case handling of SOFORT, where we consider an "network approved" initiated payment
* already fully closed & settled.
* ("first thank you" email, or the "thank you for renewing" email)
* @param {import('stripe').Stripe.Event} event
Expand All @@ -23,20 +24,16 @@ const { latestInvoiceStatusKey, paymentProcessingKey } = stripeSubscriptionKeys;
module.exports = async (event, res) => {
console.log('Handling payment_intent.processing');
const paymentIntent = /** @type {import('stripe').Stripe.PaymentIntent} */ (event.data.object);
const uid = await getFirebaseUserId(paymentIntent.customer);

// Set the user's latest invoice state
const privateUserProfileDocRef = db.doc(`users-private/${uid}`);
const privateUserProfileData = (await privateUserProfileDocRef.get()).data();

// --- Do pre-checks for the usefulness of this event for WTMG ---
// Make sure charges are listed for the paymentIntent
// @ts-ignore
if (!(paymentIntent?.charges?.data instanceof Array)) {
return res.sendStatus(200);
}

// Check if this payment was approved.
// Empirically, charges is part of the event object
// By checking, we've seen that charges is part of the event object
const processingCharge = /** @type {import('stripe').Stripe.Charge[]} */ (
// @ts-ignore
paymentIntent.charges.data
Expand All @@ -52,9 +49,17 @@ module.exports = async (event, res) => {
return res.sendStatus(200);
}

// --- Qualify the event further based on the linked invoice ---
// Fetch the related invoice
const invoice = await stripe.invoices.retrieve(paymentIntent.invoice);

// Check if the invoice is a WTMG invoice
if (!(await isWTMGInvoice(invoice))) {
// Ignore invoices that were created for payment events not related to WTMG subscriptions
console.log('Ignoring non-WTMG payment processing event');
return res.sendStatus(200);
}

// Check if the invoice is related to subscription creation
if (
!(
Expand All @@ -70,13 +75,21 @@ module.exports = async (event, res) => {
return res.sendStatus(200);
}

// --- Actually process the event ---

// Get the Firebase user
const uid = await getFirebaseUserId(paymentIntent.customer);
const privateUserProfileDocRef = db.doc(`users-private/${uid}`);
const privateUserProfileData = (await privateUserProfileDocRef.get()).data();

// Ensure the user is marked as a superfan, with a payment processing indication.
const publicUserProfileDocRef = db.doc(`users/${uid}`);
const publicUserProfileData = (await publicUserProfileDocRef.get()).data();
await publicUserProfileDocRef.update({
superfan: true
});
await privateUserProfileDocRef.update({
// Set the user's latest invoice state
// Empirically, we know that this status is "open"
[latestInvoiceStatusKey]: invoice.status,
[paymentProcessingKey]: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { sendSubscriptionEndedEmail } = require('../../mail');
const removeUndefined = require('../../util/removeUndefined');
const { stripeSubscriptionKeys } = require('../constants');
const getFirebaseUserId = require('../getFirebaseUserId');
const { isWTMGSubscription } = require('./util');

const { statusKey, cancelAtKey, canceledAtKey } = stripeSubscriptionKeys;

Expand All @@ -20,6 +21,11 @@ module.exports = async (event, res) => {
console.log('Handling customer.subscription.deleted');
/** @type {import('stripe').Stripe.Subscription} */
const subscription = event.data.object;
if (!isWTMGSubscription(subscription)) {
console.log('Ignoring non-WTMG subscription');
return res.sendStatus(200);
}

const {
customer,
start_date: startDate,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
// @ts-check
//
const getFirebaseUserId = require('../getFirebaseUserId');
const { stripeSubscriptionKeys } = require('../constants');
const removeUndefined = require('../../util/removeUndefined');
const { db } = require('../../firebase');
const { isWTMGSubscription } = require('./util');

const {
priceIdKey,
Expand All @@ -23,6 +26,11 @@ module.exports = async (event, res) => {
console.log('Handling subscription.updated');
/** @type {import('stripe').Stripe.Subscription} */
const subscription = event.data.object;
if (!isWTMGSubscription(subscription)) {
console.log('Ignoring non-WTMG subscription');
return res.sendStatus(200);
}

const uid = await getFirebaseUserId(subscription.customer);

// Save updated subscription state in Firebase
Expand Down
64 changes: 64 additions & 0 deletions api/src/subscriptions/stripeEventHandlers/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// @ts-check
const functions = require('firebase-functions');
const stripe = require('../stripe');

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

exports.wtmgPriceIds = wtmgPriceIds;

function isWTMGPriceId(priceId) {
return wtmgPriceIds.includes(priceId);
}
exports.isWTMGPriceId = isWTMGPriceId;

/**
* @param {import('stripe').Stripe.Invoice} invoice
*/
exports.isWTMGInvoice = async function (invoice) {
// eslint-disable-next-line camelcase
const { lines, subscription, amount_paid } = invoice;
const lineOne = lines.data[0];
let price = null;
if (lineOne != null && lineOne.price?.type === 'one_time') {
// In this case, the plan is null, and then this was a modified invoice
// (the customer changed their mind),
// which had a new one-time price & product id generated for it by Stripe
// Get the actual (hopefully!) current product & price from the subscription (?)
// do verify with the amount?
// if price->type is one_time instead of recurring
// TODO: we should consider cancelling & recreating subs on change instead, to avoid confusing issues like this.
//
// The sub will contain the actual price ID of the sub plan.
const sub = await stripe.subscriptions.retrieve(/** @type {string} */ (subscription));
price = sub.items.data[0].price;
// eslint-disable-next-line camelcase
if (price.unit_amount !== amount_paid) {
// check if this person perhaps updated their sub two times?? :S
// TODO 1: is the price ID info now used in an intergration? ARE THE FAKE ONES
// in our Firebase? That could lead to mega weirdness
// TODO 2: update the product ID too (that one is also unique per invoice)
console.error(`Weirdness occurred in ${sub.id}`);
}
} else {
// Normal case
price = lineOne.price;
}

const priceId = price?.id;
if (isWTMGPriceId(priceId)) {
return true;
}
return false;
};

/**
* @param {import('stripe').Stripe.Subscription} subscription
*/
exports.isWTMGSubscription = function (subscription) {
const priceId = subscription.items.data[0].price.id;
if (isWTMGPriceId(priceId)) {
return true;
}
return false;
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"dev": "vite --host",
"dev:staging": "vite --mode staging --host",
"dev:push": "vite --mode devpush --host",
"dev:prod": "vite --mode production --host",
"build": "vite build",
"build:prod": "vite build --mode production",
"build:staging": "vite build --mode staging",
Expand Down
26 changes: 13 additions & 13 deletions src/lib/api/firebase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
import { initializeAppCheck, ReCaptchaV3Provider } from 'firebase/app-check';
import { initializeAppCheck, ReCaptchaV3Provider, type AppCheck } from 'firebase/app-check';
import { connectAuthEmulator, getAuth, type Auth } from 'firebase/auth';
import { type Firestore, getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
import { connectStorageEmulator, getStorage, type FirebaseStorage } from 'firebase/storage';
Expand All @@ -25,22 +25,19 @@ const FIREBASE_CONFIG = {
};

const messageFor = (str: string) => `Trying to use an uninitialized ${str}.`;
type FirestoreWarning = {
app: string;
firestore: string;
auth: string;
storage: string;
functions: string;
messaging: string;
};
export const FIREBASE_WARNING: FirestoreWarning = [

const firestoreWarningKeys = [
'app',
'firestore',
'auth',
'storage',
'functions',
'messaging'
].reduce(
'messaging',
'appCheck'
] as const;
type FirestoreWarning = { [key in (typeof firestoreWarningKeys)[number]]: string };

export const FIREBASE_WARNING: FirestoreWarning = firestoreWarningKeys.reduce(
(warningsObj, service) => ({ ...warningsObj, [service]: messageFor(service) }),
{}
) as FirestoreWarning;
Expand Down Expand Up @@ -78,6 +75,9 @@ export const db: () => Firestore = guardNull<Firestore>(() => dbRef, 'firestore'
let authRef: Auth | null = null;
export const auth: () => Auth = guardNull<Auth>(() => authRef, 'auth');

let appCheckRef: AppCheck | null = null;
export const appCheck: () => AppCheck = guardNull<AppCheck>(() => appCheckRef, 'appCheck');

let usCentral1FunctionsRef: Functions | null = null;
export const functions: () => Functions = guardNull<Functions>(
() => usCentral1FunctionsRef,
Expand Down Expand Up @@ -130,7 +130,7 @@ export async function initialize(): Promise<void> {
}

if (typeof import.meta.env.VITE_FIREBASE_APP_CHECK_PUBLIC_KEY !== 'undefined') {
initializeAppCheck(appRef, {
appCheckRef = initializeAppCheck(appRef, {
provider: new ReCaptchaV3Provider(import.meta.env.VITE_FIREBASE_APP_CHECK_PUBLIC_KEY),
isTokenAutoRefreshEnabled: true
});
Expand Down
Loading

0 comments on commit 89a6cae

Please sign in to comment.