Collection setup for a basic SaaS using stripe

I’m setting up a basic SaaS. This will be a node.js app backed by mongoDB/graphql.

I’m just looking for some ideas on setting up my collections/types. How have you done it in the past? Anything you didn’t track that you wished you tracked? Anything you tracked that was a waste of time tracking? Any good naming conventions for collections? Do you store the subscription status on the a User object? How many webhook endpoints did you create for stripe to talk to? Looking for tips along those lines.

My app will start with just one basic subscription, but could move to a three tiered approach in the future.

2 Likes

We track everything - its invaluable for debugging:

  • invoices
  • subscriptions
  • log every webhook stripe sends us

we set up a single webhook that receives every event from stripe and processes it accordingly. There’s no real difference between a single subscription and multiple tiers of subscriptions, beyond your application logic of what to do in that scenario. e.g., you may grant different permissions based on different product ID’s from stripe.

We track the customer object on the user, but the subscription object in its own collection. This allows a user to transfer a subscription to another user (which is critical for us as organisations pay for a subscription, not users). If you have a 1:1 mapping of users to subscriptions, you could track subscriptions on the user.

Just be weary of how much data you are storing on the Meteor.users collection. If you store transaction logs on the user object then it will grow indefinitely as long as the user keeps resubscribing.

Many meteor packages, including some core packages, call Meteor.user() or Meteor.users.find/findOne() to get the full user object even if they don’t need all of it. E.g. Meteor.user().profile.name on the server fetches the full user object from the db. Very wasteful. The accounts package does something similar on every user log in/out.

See my bug report here (unlikely to be fixed) and another feature suggestion here.

I didn’t realise this until it was too late: one of my app logs every user’s interaction and stores it on the user object - which is the logical (and recommended) place for it. Every users object quickly became huge and the db bandwidth was unnecessarily large and slow.

1 Like

That section definitely needs editing. Can you make a pull request with the same warning you’ve posted here?

1 Like

I would rather create a PR to fix the underlying issue, but would it have any chance of being merged? I’ve done it myself with my own forked accounts-base package.

There are many internal fixes which are trivial and non-breaking. The only real problem is with the login/out callbacks where it’s not known which fields are required by each callback. With my fix I’ve introduced my own Accounts.callbackFields object so that the login/out callbacks only fetch a limited set of fields. A better solution would be for the callback registration to specify which fields are required. Either way this is introducing new public API which is unlikely to be accepted for what is considered to be an edge case (I see my bug as recently been labelled ‘Impact:few’ which I disagree with - who doesn’t store extra data on the user object?)

My issue has been upgraded to ‘pull-requests-encouraged’ and I’m working my first Meteor PR… :grinning:

3 Likes

Yesterday I realized this was a huge issue for me as well – been storing quite a bit of data on the user object. Saw your pull request/updates, and am very excited that they’re set to be bundled in 1.10. Thanks @wildhart!

Thanks @jasongrishkoff!

In the meantime, feel free to download the accounts-base and accounts-password packages from my fork and put them in your packages folder. I’ve been using them in production for a while. Would be great if you could share any before/after stats, if it can make a measurable difference…

Happy to test, but struggling a bit to figure out how to do that or even where to find the fork! Is it this one? https://github.com/wildhart/meteor/tree/devel/packages/accounts-base
Doesn’t seem right given it was last updated in April of 2019!

You have to go to the fix-issue-10469 branch, download the zip file from the green “Clone or download” button, then copy the two packages from there into your own packages folder.

Great, got it set up! Just had to change the package version to @1.5.1 because I believe Meteor@1.9 uses @1.5.0 while your repo has @1.4.5. Sound about right?

Yup, that sounds right. You’ll need the accounts-base package as well. I haven’t upgraded to 1.9 yet, but I confirmed that neither of those packages changed.

To get the most benefit you’ll need to set the new Accounts.config({defaultFieldSelector: {...}}) which can be used to limit the default fields fetched in any onLogin/out callbacks, Meteor.user() calls, etc.

You can either whitelist the fields to the Meteor standard fields:

Accounts.config({
  defaultFieldSelector: {username: 1, emails: 1, createdAt: 1, profile: 1, services: 1}
});

Or you can blacklist your own data:

Accounts.config({
  defaultFieldSelector: {myBigArray: 0}
})

The blacklist is safer in case a 3rd party package adds their own data to the User object and expects it to be present in the onLogin/out hooks or Meteor.user().

Then you need to search and replace all your inefficient calls to Meteor.user(), (e.g.):

// BAD - fetches the full user object (unless defaultFieldSelector is used):
// server - will cause unnecessary data retrieval from db
// client - will cause unnecessary reactive UI updates
Meteor.user().profile.name

// GOOD!
Meteor.user({fields: {'profile.name': 1}}).profile.name

Bear in mind that my PR hasn’t had final review yet so this new API might get changed. As long as you have my forked packages in your packages folder you’ll be OK, but once this gets merged check the API hasn’t been changed before removing the forked packages.

Let us know how it goes…

Epic, thanks! When setting this:

Accounts.config({
  defaultFieldSelector: {myBigArray: 0}
})

Does that apply everywhere unless otherwise specified? So anywhere (on client or server) where Meteor.user() is called without any config, it applies? Or is that only relevant to the Accounts package (accounts-base and accounts-password)?

What about packages like accounts-google and accounts-facebook?

The defaultFieldSelector will work everywhere in the client and server, if it is defined in both contexts. Any lazy call to Meteor.user() will apply the defaultFieldSelector.

The accounts-google, etc, packages don’t really do much except store some data in the user’s services object, so should still work fine. All the tests still pass.

Perfect, thanks! I’ve set the defaultFieldSelector on the server side only, as I use Meteor.user() all over the place on the client side. I also updated all server-side Meteor.user() calls to specify the necessary {fields:{}}.

I’ve got it running in production now, and the difference is like night and day.

Using APM to trace inefficient user calls was also very helpful – I noticed that the splendido:accounts-emails-field was making calls with onLogin that loaded the full user object and also re-wrote the registered_emails field to the user every time, even if there was no change. Maybe something to revisit @splendido? In the meanwhile I’ve created my own variation of that package to minimize database action.

Will update you if I notice anything else worth mentioning!

1 Like

I use this code to help me track down lazy calls to Meteor.user():

const _origMeteorUser = Meteor.user;
Meteor.user = function(options) {
	if (!options) {
		console.log('Meteor.user() called without any field specifier');
		console.trace();
	}
	return _origMeteorUser(options);
}

You can wrap it all in if (!Meteor.isProduction) {} if you want.

I’ve tried to specify the required user fields even on the client because it can reduce unnecessary re-renders, even though the user probably doesn’t notice!

1 Like

Thanks for that tip! Quick question – under _attemptLogin of accounts-base/accounts_server.js there’s a call for user = this.users.findOne(result.userId, {fields: this._options.defaultFieldSelector});. I’ve tried following this one through to the end of the function to see which fields are actually being used, but I’m unsure. Is it possible for _attemptLogin to specify the right fields instead of using the defaultFieldSelector?

_attemptLogin doesn’t consume the user document itself. Instead, it embeds it within the attempt object and within the lines…

this._validateLogin(methodInvocation.connection, attempt);
...
this._successfulLogin(methodInvocation.connection, attempt);
...
this._failedLogin(methodInvocation.connection, attempt);
...
etc

…it passes it to any callbacks registered within Accounts.validateLoginAttempt(), Accounts.onLogin() and Accounts.onLoginFailure(), etc.

Because we don’t know which fields the user needs within their callbacks, I created the defaultFieldsSelector setting so users like us who store lots of data on the user object can specify or limit the fetched fields.

Makes sense. Thanks for clarifying!