@marsbaa/payments
v0.2.1
Published
Nuxt module for Stripe subscription management
Readme
@marsbaa/payments
Nuxt module for Stripe subscription management. Provides server API endpoints and client composables for managing recurring subscriptions.
Installation
npm install @marsbaa/payments
# or
pnpm add @marsbaa/paymentsConfiguration
1. Add the module to your Nuxt config
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@marsbaa/payments"],
payments: {
// Your Stripe Price ID for the subscription
priceId: "price_xxxxx",
// Optional: Billing storage configuration
// Defaults to subcollection mode with 'communities' collection
ownerCollection: "communities", // Collection containing owner documents
billingMode: "subcollection", // 'subcollection' or 'embedded'
billingKey: "billing", // Field/subcollection name for billing data
subscriptionDocId: "subscription", // Document ID in subcollection mode
},
});2. Set environment variables
The module requires the following environment variables:
| Variable | Description | Required |
| ------------------------------------ | ------------------------------------ | -------- |
| STRIPE_SECRET_KEY | Your Stripe secret key (server-side) | Yes |
| STRIPE_WEBHOOK_SECRET | Stripe webhook endpoint secret | Yes |
| NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | Stripe publishable key (client-side) | Yes |
# .env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx3. Implement the context resolver
The module needs to know the current user and community context. Set this in server middleware:
// server/middleware/payment-context.ts
export default defineEventHandler((event) => {
// Get user from your auth system
const user = event.context.auth?.user;
if (user) {
event.context.payment = {
userId: user.id,
communityId: user.currentCommunityId,
};
}
});4. Set up Stripe webhook
In your Stripe Dashboard:
- Go to Developers > Webhooks
- Add endpoint:
https://your-domain.com/api/payments/webhook - Select events:
invoice.payment_succeededinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
- Copy the webhook signing secret to
STRIPE_WEBHOOK_SECRET
Billing Storage Configuration
The module supports flexible billing data storage to accommodate different Firestore schemas. You can choose between two modes: subcollection (default) and embedded.
Subcollection Mode (Default)
Billing data is stored in a subcollection under each owner document. This is the default behavior and maintains backwards compatibility.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@marsbaa/payments"],
payments: {
priceId: "price_xxxxx",
ownerCollection: "communities", // Your owner collection name
billingMode: "subcollection", // Default
billingKey: "billing", // Subcollection name
subscriptionDocId: "subscription", // Document ID within subcollection
},
});Firestore Structure:
communities/{ownerId}/billing/subscriptionEmbedded Mode
Billing data is stored as a field directly in the owner document. This can be more efficient for simple schemas.
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@marsbaa/payments"],
payments: {
priceId: "price_xxxxx",
ownerCollection: "users", // Your owner collection name
billingMode: "embedded",
billingKey: "subscriptionData", // Field name in owner document
},
});Firestore Structure:
users/{ownerId}
├── subscriptionData: {
│ status: 'active',
│ plan: 'shared-care-rhythm',
│ stripeCustomerId: '...',
│ stripeSubscriptionId: '...',
│ currentPeriodEnd: Timestamp,
│ ...
│ }Required Firestore Index (Embedded Mode):
For webhook processing to work correctly in embedded mode, create a composite index on {billingKey}.stripeSubscriptionId:
- Go to Firebase Console > Firestore > Indexes
- Create a new composite index with:
- Collection:
{ownerCollection}(e.g.,users) - Fields:
{billingKey}.stripeSubscriptionId(ascending)
- Collection:
- The index will be named something like
users-subscriptionData.stripeSubscriptionId
Migration Guide
Changing Owner Collection
To migrate from the default communities collection to a different collection (e.g., organizations):
Update configuration:
// nuxt.config.ts export default defineNuxtConfig({ payments: { ownerCollection: "organizations", // Changed from 'communities' // ... other config }, });Migrate existing data:
- Export billing data from
communities/{id}/billing/subscription - Import to new location based on your
billingMode - Update any references in your application code
- Export billing data from
Update context resolver:
// server/middleware/payment-context.ts event.context.payment = { userId: user.id, communityId: user.currentOrganizationId, // Changed from currentCommunityId };
Switching Between Subcollection and Embedded Modes
From Subcollection to Embedded:
- Backup your data - This migration requires data transformation
- Update configuration:
payments: { billingMode: 'embedded', billingKey: 'billing' // Field name for embedded data } - Migrate data:
- Read from
ownerCollection/{id}/billing/subscription - Write to
ownerCollection/{id}with field{billingKey} - Delete old subcollection documents
- Read from
- Create required Firestore index (see embedded mode setup)
From Embedded to Subcollection:
- Backup your data
- Update configuration:
payments: { billingMode: 'subcollection', billingKey: 'billing', // Subcollection name subscriptionDocId: 'subscription' } - Migrate data:
- Read from
ownerCollection/{id}.{billingKey} - Write to
ownerCollection/{id}/billing/subscription - Remove the embedded field from owner documents
- Read from
Important Notes:
- Migrations should be performed during maintenance windows
- Test thoroughly in a staging environment first
- Webhooks will continue processing during migration if both old and new data exist
- Consider using Firestore's export/import features for large datasets
Usage
Using the composable
<script setup lang="ts">
const {
isLoading,
error,
lastResult,
confirmSharedCareSupport,
pauseSharedCareSupport,
cancelSharedCareSupport,
resumeSharedCareSupport,
} = usePayment();
// Create a subscription
async function handleSubscribe(paymentMethodId: string) {
const result = await confirmSharedCareSupport({
paymentMethodId,
});
if (result) {
console.log("Subscription created:", result.subscriptionId);
} else if (error.value) {
console.error("Error:", error.value.code);
}
}
// Pause subscription
async function handlePause() {
await pauseSharedCareSupport();
}
// Cancel subscription
async function handleCancel() {
await cancelSharedCareSupport();
}
// Resume paused subscription
async function handleResume() {
await resumeSharedCareSupport();
}
</script>
<template>
<div>
<p v-if="isLoading">Processing...</p>
<p v-if="error">Error: {{ error.code }}</p>
</div>
</template>Using with Stripe Elements
<script setup lang="ts">
import { loadStripe } from "@stripe/stripe-js";
const config = useRuntimeConfig();
const stripe = await loadStripe(config.public.stripePublishableKey);
const { confirmSharedCareSupport, error } = usePayment();
// Mount Stripe Elements card
const elements = stripe?.elements();
const cardElement = elements?.create("card");
cardElement?.mount("#card-element");
async function handleSubmit() {
if (!stripe || !cardElement) return;
// Create payment method
const { paymentMethod, error: stripeError } =
await stripe.createPaymentMethod({
type: "card",
card: cardElement,
});
if (stripeError) {
console.error(stripeError);
return;
}
// Confirm subscription
await confirmSharedCareSupport({
paymentMethodId: paymentMethod.id,
});
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div id="card-element"></div>
<button type="submit">Subscribe</button>
</form>
</template>API Reference
Composable: usePayment()
Returns:
| Property | Type | Description |
| -------------------------- | ---------------------------- | -------------------------------------- |
| isLoading | Ref<boolean> | True while an operation is in progress |
| error | Ref<PaymentError \| null> | Error from last operation |
| lastResult | Ref<PaymentResult \| null> | Result from last successful operation |
| confirmSharedCareSupport | (params) => Promise | Create and confirm subscription |
| pauseSharedCareSupport | () => Promise | Pause active subscription |
| cancelSharedCareSupport | () => Promise | Cancel subscription at period end |
| resumeSharedCareSupport | () => Promise | Resume paused subscription |
Error Codes
| Code | Description |
| ------------------------ | -------------------------------------------------- |
| auth_required | User is not authenticated |
| invalid_context | Missing userId or communityId |
| invalid_state | Invalid status transition (e.g., pause non-active) |
| payment_failed | Stripe payment failed |
| network_error | Network or connection error |
| subscription_not_found | No subscription exists |
| config_missing | Required config not set |
| invalid_signature | Webhook signature invalid |
| signature_missing | Webhook signature header missing |
| stripe_error | Generic Stripe API error |
Billing Status Flow
pending → active → paused → active
↘ ↗
cancelledValid transitions:
pending→active,cancelledactive→paused,cancelledpaused→active,cancelledcancelled→ (none)
Types
import type {
BillingStatus,
BillingPlan,
BillingRecord,
PaymentError,
PaymentErrorCode,
PaymentResult,
} from "@marsbaa/payments";Firebase Schema
Billing records are stored according to your billingMode configuration:
Subcollection Mode (Default):
{ownerCollection}/{ownerId}/{billingKey}/{subscriptionDocId}Embedded Mode:
{ownerCollection}/{ownerId}.{billingKey}Structure:
interface BillingRecord {
status: "pending" | "active" | "paused" | "cancelled";
plan: "shared-care-rhythm";
stripeCustomerId: string;
stripeSubscriptionId: string;
currentPeriodEnd: Timestamp;
lastPaymentError?: string;
lastPaymentAttemptAt?: Timestamp;
}Requirements
- Nuxt 3.0+
- Firebase Admin SDK configured in your app
- Stripe account with subscription product/price created
License
MIT
