npm package: @marsbaa/payments
Description: Nuxt module for Stripe subscription management
Latest version: 0.2.1

# @marsbaa/payments

Nuxt module for Stripe subscription management. Provides server API endpoints and client composables for managing recurring subscriptions.

## Installation

```bash
npm install @marsbaa/payments
# or
pnpm add @marsbaa/payments
```

## Configuration

### 1. Add the module to your Nuxt config

```typescript
// 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      |

```bash
# .env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
```

### 3. Implement the context resolver

The module needs to know the current user and community context. Set this in server middleware:

```typescript
// 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:

1. Go to **Developers > Webhooks**
2. Add endpoint: `https://your-domain.com/api/payments/webhook`
3. Select events:
   - `invoice.payment_succeeded`
   - `invoice.payment_failed`
   - `customer.subscription.updated`
   - `customer.subscription.deleted`
4. 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.

```typescript
// 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/subscription
```

### Embedded Mode

Billing data is stored as a field directly in the owner document. This can be more efficient for simple schemas.

```typescript
// 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`:

1. Go to **Firebase Console > Firestore > Indexes**
2. Create a new composite index with:
   - Collection: `{ownerCollection}` (e.g., `users`)
   - Fields: `{billingKey}.stripeSubscriptionId` (ascending)
3. 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`):

1. **Update configuration:**

   ```typescript
   // nuxt.config.ts
   export default defineNuxtConfig({
     payments: {
       ownerCollection: "organizations", // Changed from 'communities'
       // ... other config
     },
   });
   ```

2. **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

3. **Update context resolver:**
   ```typescript
   // 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:**

1. **Backup your data** - This migration requires data transformation
2. **Update configuration:**
   ```typescript
   payments: {
     billingMode: 'embedded',
     billingKey: 'billing' // Field name for embedded data
   }
   ```
3. **Migrate data:**
   - Read from `ownerCollection/{id}/billing/subscription`
   - Write to `ownerCollection/{id}` with field `{billingKey}`
   - Delete old subcollection documents
4. **Create required Firestore index** (see embedded mode setup)

**From Embedded to Subcollection:**

1. **Backup your data**
2. **Update configuration:**
   ```typescript
   payments: {
     billingMode: 'subcollection',
     billingKey: 'billing', // Subcollection name
     subscriptionDocId: 'subscription'
   }
   ```
3. **Migrate data:**
   - Read from `ownerCollection/{id}.{billingKey}`
   - Write to `ownerCollection/{id}/billing/subscription`
   - Remove the embedded field from owner documents

**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

```vue
<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

```vue
<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
                 ↘        ↗
                   cancelled
```

Valid transitions:

- `pending` → `active`, `cancelled`
- `active` → `paused`, `cancelled`
- `paused` → `active`, `cancelled`
- `cancelled` → (none)

## Types

```typescript
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:**

```typescript
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