Current LaizyNote dashboard of the SaaS application

SaaS billing does not end at a successful Stripe Checkout. That is where the actual product logic starts: Which plan does the user own? Which features may they use? What happens after cancellation, a failed payment attempt or a delayed webhook?

I built this connection for LaizyNote. Stripe handles payments, invoices and tax calculations. Firebase Authentication identifies the user, Cloud Functions process payment events and Firestore stores the subscription state used by the application. This deep dive complements my 16-month review of building LaizyNote with that technical layer.

A payment is not an entitlement

LaizyNote has three plans: Free, Solo and Plus. Monthly and annual billing, coupons, referral bonuses, temporary access and additional Daisy credits add more states behind a seemingly simple plan selector.

The application must never trust a button or URL parameter alone. A user can cancel Checkout, open it twice or already have an active subscription. A webhook can arrive late and a payment can fail after initial activation. The stored plan therefore has to be derived from verified Stripe data.

The flow in five steps
  1. The authenticated user selects a plan and billing cycle.
  2. A Cloud Function validates the choice and creates Stripe Checkout.
  3. Stripe confirms payment and subscription through signed webhooks.
  4. Webhook logic writes plan, status and period to Firestore.
  5. The frontend and protected backend functions read the effective plan.

From Checkout to plan without duplicate subscriptions

The Checkout Session is created on the server. The Cloud Function verifies authentication and accepts only known price IDs. It also looks for existing Stripe customers and active subscriptions. If a subscription already exists, a second one is blocked and changes are handled through Stripe's customer portal instead.

From Stripe Checkout to the effective plan Write path: plan selection, Cloud Function, Stripe Checkout and a signed webhook write the subscription document to Firestore. Read path: getEffectiveTier derives the effective plan, which the frontend and protected Cloud Functions check separately. A nightly Stripe sync and a limit audit reconcile the state. Write path User picks a planFree · Solo · PlusCloud Functionchecks auth, price ID, existing subscriptionStripe Checkoutpayment & taxStripesubscription createdFirestore: subscription docplan · status · period · cancellationgetEffectiveTier()base tier + bonus, never downgradingFrontendshows/hides — not a security boundaryCloud Functionchecks again: backups, automationNightly Stripe syncrepairs missed webhooksLimit auditreconciles usage counters signed webhook Derivation Enforcement
Stripe is the source of truth for payment state, not the application database. Webhook processing translates Stripe objects into a lean Firestore document; only getEffectiveTier() decides which plan applies. The dashed paths are the safety net for missed or contradictory states.

A short-lived per-user lease catches concurrent Checkout attempts. Idempotency keys further ensure that a repeated request does not create multiple Stripe objects. These details are invisible but prevent duplicate subscriptions caused by double clicks, slow connections or retries.

After Checkout, LaizyNote processes events including:

  • checkout.session.completed: associates the purchase with the Firebase user and activates the plan.
  • customer.subscription.updated/deleted: reconciles plan changes and cancellations.
  • invoice.paid: records a successful payment.
  • invoice.payment_failed: marks the payment issue for the UI and further processing.

Stripe is the source of truth for payment state, but not a database every application view queries live. Webhook processing translates Stripe objects into a compact subscription document in Firestore. It contains the plan, billing cycle, current period, cancellation at period end and flags for failed payments.

This projection separates the product interface from Stripe latency and API limits. LaizyNote can load the plan with the rest of the account data rather than making an external request on every navigation. The underlying Stripe subscription and price remain traceable.

Mapping a user to a Stripe customer is a failure case of its own. Checkout and webhooks therefore carry Firebase UIDs in metadata and maintain a reverse customer mapping. Stale or deleted Stripe objects are handled as well, preventing an obsolete pointer from blocking a future Checkout permanently.

One function determines the effective plan

The stored plan alone is not enough. LaizyNote calculates an effective plan from subscription tier, Stripe status and possible bonus access. This central function is the single decision point for Free, Solo or Plus.

The active and trialing states are straightforward. For past_due or unpaid, the plan initially remains entitled while the payment issue is visibly flagged. Features therefore do not disappear immediately because of an expired payment method. Ended or non-entitled subscriptions fall back to Free.

Temporary bonus access sits as a separate layer above the base plan. A bonus can temporarily move Free to Solo or Solo to Plus, but never reduce a higher existing plan. Expiry is calculated from a timestamp rather than depending on somebody manually updating the document.

TypeScript
// Stripe statuses that keep the paid plan in force.
// past_due and unpaid stay in deliberately: Stripe is still retrying,
// so access should not drop on the first failed payment.
const ENTITLED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
const TIER_ORDER = { free: 0, solo: 1, plus: 2 }

// Base tier: the stored plan, but only if the Stripe status supports it.
function getBaseTier(sub) {
  const stored = normalizeTier(sub?.tier)
  const hasStripeSub = typeof sub?.stripeSubscriptionId === 'string'
    && sub.stripeSubscriptionId.trim().length > 0

  return hasStripeSub && !ENTITLED_STATUSES.has(sub?.status)
    ? 'free'
    : stored
}

// Bonus access sits on top as its own layer and expires purely by time -
// nobody has to touch the document for that.
function getEffectiveTier(sub, now = new Date()) {
  const baseTier = getBaseTier(sub)
  if (!isBonusAccessActive(sub, now)) return baseTier

  const bonusTier = normalizeTier(sub?.bonusAccessTier)
  // A bonus lifts the plan, but never lowers it.
  return TIER_ORDER[bonusTier] > TIER_ORDER[baseTier] ? bonusTier : baseTier
}

This central calculation prevents modules from inventing different interpretations of the same status. Without it, the pricing screen might display Plus while the backup function reads only the raw tier field and reaches another conclusion. The effective-plan function is small but architecturally important.

Team workspaces add another question: whose plan applies? LaizyNote does not treat every invited member as a subscriber. Paid workspace features follow the owner. A Free user therefore does not accidentally receive Plus features in their own area merely because they collaborate in somebody else's team.

Why features are checked on the server

The frontend presents features according to the plan and stops users adding more content once a Free limit is reached. That makes the product understandable, but it is not a sufficient security boundary.

Valuable features therefore verify the plan again in Cloud Functions. Manual backups require at least Solo; automatic backups and automation rules require Plus. For team workspaces, access is based on an entitled workspace owner rather than any member. Daisy and its usage logic also rely on server-side plan information.

A hidden button does not protect a premium feature

Frontend checks explain what the user can do. The actual entitlement check must happen where the protected action executes; otherwise the API could be called directly.

LaizyNote uses usage counters for quantity limits. A nightly audit compares those counters with the contacts, notes, tasks, projects and workspaces actually stored. Drift is reported and corrected, keeping the hot write path fast without recounting every document on every action.

Automated tests cover the transitions. They exercise successful Checkout as well as repeated requests, invalid price IDs, existing subscriptions, expired bonus access, webhook retries and failed payments. In billing code, a happy-path test is not enough; repeated and partially completed flows are where mistakes become expensive.

Webhook processing has to be idempotent because Stripe can deliver the same event again. An already processed purchase must not trigger another plan change or duplicate credits. Separately purchased Daisy credits therefore store their Stripe Session as a unique processing key.

Cancellation without data loss

Cancellation does not mean an immediate switch to Free. When Stripe reports cancel_at_period_end, the paid plan remains active through the end of the billing period. Scheduled synchronization then returns the account to Free.

No notes, tasks or projects are deleted. Existing content remains available when it exceeds Free limits; the user simply cannot add more until content is reduced or the plan is upgraded again. Account deletion is deliberately separate and permanently removes data and backups.

Invoices, payment methods, cancellation and plan changes live in Stripe's customer portal. Stripe Tax also validates VAT IDs for European businesses and supports reverse charge, avoiding the need to recreate those complex billing screens inside LaizyNote.

The boundary between custom software and payment provider is deliberate. LaizyNote decides which product feature belongs to which plan and how existing data behaves after a downgrade. Stripe handles payment methods, invoices, tax calculations and payment attempts. Rebuilding those responsibilities inside the application would add a much larger set of financial and legal states to maintain safely.

Why webhooks are not enough

Webhooks are the fastest way to receive Stripe changes, but consistency cannot depend on every event arriving on its first attempt. A scheduled Cloud Function therefore reconciles all stored Stripe subscriptions against Stripe each night.

A second job handles expired cancellations and bonus access, followed by the usage-limit audit. These staggered checks create a safety net: webhooks provide fast updates and scheduled reconciliation repairs missed or contradictory state.

Native Stripe architecture or WooCommerce?

In the SVE license system, WooCommerce Subscriptions handles large parts of orders, subscription management and user assignment. That was economically sensible for a standalone calculation tool with a WordPress backend.

LaizyNote is the complete SaaS product itself. Vue and Firebase already provide the application, user accounts and data model. Adding WordPress just for billing would have created a second user and data domain. Direct Stripe integration avoids that duplication but requires custom code for Checkout, webhooks, reconciliation, entitlements and failure states.

Stripe + Cloud FunctionsWooCommerce Subscriptions
Fitsa standalone app with its own user accountsa product where WordPress is already the platform
User managementalready in place (Firebase Authentication)WordPress users, a second user world
Subscription logicbuilt in-house: webhooks, plan resolution, synclargely covered by the plugin
Edge cases (bonus, limits, teams)free to modelbound to the plugin data model
Upfront efforthighlow
Ongoing maintenanceyour own code, but no plugin updatesplugin, theme and WordPress updates
What Stripe covers either waypayment methods, invoices, tax calculation, retries, customer portal
Architecture follows the product
  • WooCommerce Subscriptions: faster when WordPress is already the central platform.
  • Stripe + Cloud Functions: more direct and flexible for an independent Firebase application.
  • Neither is universally better: the existing stack, required custom logic and maintenance effort decide.

What I would plan earlier next time

I would again build billing around Stripe and a centrally calculated effective plan. What I would change is timing: plans, limits, cancellation states, bonus access and the user-to-workspace relationship belong in the data model early, not shortly before launch.

The hardest part is not Checkout. It is the transitions: paid to overdue, active to cancelled, bonus to regular subscription and Plus back to Free without losing data. Those transitions turn payment integration into resilient SaaS architecture. Systems like this are part of my custom web development work.

Frequently asked questions about SaaS billing

How is a LaizyNote subscription activated?

After a successful Stripe Checkout, a Cloud Function processes the webhook event and stores the plan, billing cycle and status in the user subscription document.

What happens when a payment fails?

The payment status is stored and displayed in the app. For past_due and unpaid states, LaizyNote does not remove access immediately while Stripe manages payment retries and billing.

What happens after cancellation?

The subscription remains active until the end of the paid period. The account then returns to Free; existing content remains, but no new content can be added when Free limits are exceeded.

Why is hiding features in the frontend not enough?

Frontend restrictions improve usability but are not a security boundary. Valuable functions such as backups and automations therefore verify the effective plan on the server as well.

Which is more work: integrating Stripe directly or using WooCommerce?

WooCommerce Subscriptions gets you started faster because orders, subscriptions and user mapping ship with it. A direct Stripe integration costs more time upfront but is more flexible once custom plan rules, limits or team logic enter the picture.

How are accidental duplicate subscriptions prevented?

The Checkout session is created exclusively on the server. A Cloud Function first checks whether the user already has a Stripe customer with an active subscription; changes then go through the customer portal instead of a second checkout. A short-lived per-user lock and idempotency keys additionally ensure that double clicks or repeated requests do not create a second Stripe object.

Are you planning a SaaS or license system?

I build billing, entitlement and product logic around your existing stack, from Stripe and Firebase to WooCommerce.

Discuss your SaaS project