Developer Docs

Payments Subgraph

The payments subgraph manages the complete financial lifecycle of the Mallnline platform โ€” from product transactions and subscription management to cross-subgraph tier enforcement. It acts as the single source of truth for billing state and exposes both a GraphQL API and a TCP microservice transport for internal queries.

Port: 3014 ยท Federation: Apollo v2 ยท Database: MongoDB (transactions, subscriptions, connected_accounts collections)


Architecture

apps/payments/src/
โ”œโ”€โ”€ transaction/           โ€” Transaction entity, resolver, service (Stripe PaymentIntents)
โ”œโ”€โ”€ subscription/          โ€” BillingSubscription entity, resolver, service, TCP controller
โ”œโ”€โ”€ connected-account/     โ€” Stripe Connect accounts (seller payouts)
โ”œโ”€โ”€ provider/              โ€” Payment provider abstraction (Stripe, PayPal, M-Pesa, Paystack)
โ””โ”€โ”€ webhook/               โ€” Stripe webhook controller + verification

Supported Providers

We utilize a Payment Handler Pattern to support a wide range of payment methods across various regions while exposing a single, consistent GraphQL API to the platform.

Global Providers

  • Stripe: Primary provider for credit/debit cards globally. Supports 3D Secure and granular Auth/Capture workflows.
  • PayPal: Global wallet and alternative card processing.

Regional Providers

  • M-Pesa: Mobile money provider primarily serving East Africa (Kenya, Tanzania).
  • Paystack: Premier gateway for West African markets, supporting NGN, GHS, ZAR, and USD via cards, USSD, and bank transfers.

NOTE

For the F&F beta, only Stripe is active. M-Pesa and Paystack integration code exists but is not yet configured for production.


Stablecoin Payments (Phase 1)

Ngwenya supports Stripe-managed USDC as a payment rail for Phase 1 of the Stablecoin Commerce initiative. This approach allows seamless acceptance of USDC via the existing Stripe integration without managing private keys or smart contracts.

Architecture

  • Transaction Fields: The Transaction model includes cryptoNetwork, cryptoTxHash, and walletAddress (optional fields) when a payment is made via crypto.
  • Payment Intent: A dedicated createCryptoPaymentIntent mutation delegates to StripeHandler and enforces payment_method_types: ['crypto'].
  • Webhook Reconciliation: The WebhookService automatically extracts crypto metadata (network, transaction hash) from payment_intent.succeeded events.
  • Provider Reference: All crypto payments are logged with the internal provider name stripe_crypto.

For the broader strategy, reference the docs/research/stablecoin-integration-analysis.md document in the platform repository.


Transaction Lifecycle

Every Murchase on the platform generates a Transaction entity that models the financial state:

stateDiagram-v2
    [*] --> PENDING: Create Transaction
    PENDING --> AUTHORIZED: Authorization Success
    PENDING --> FAILED: Authorization Failed
    AUTHORIZED --> CAPTURED: Capture Success
    AUTHORIZED --> FAILED: Capture Failed
    AUTHORIZED --> VOIDED: Cancel Authorization
    CAPTURED --> REFUNDED: Refund Processed
    CAPTURED --> PARTIALLY_REFUNDED: Partial Refund

State Definitions

Status Description Action Required
PENDING Transaction intent created, awaiting provider response Wait for webhook or timeout
AUTHORIZED Funds held on buyer's payment method, not yet settled Capture on fulfillment, or void if canceled
CAPTURED Funds transferred to Malet Owner/Platform May trigger partial or full refund
PARTIALLY_REFUNDED A fraction of the original amount was refunded Continue refunding or close
REFUNDED Complete amount returned to buyer Terminal
VOIDED Authorization canceled before capture Terminal
FAILED Transaction failed (NSF, declined, technical error) Inform buyer to retry

Subscription System

The subscription system manages both Malet-scoped subscriptions (e.g., a reader subscribing to an Author Malet) and Org-scoped subscriptions (e.g., a team upgrading to Pro plan).

Subscription Entity

type BillingSubscription @key(fields: "id") {
  id: ID!
  userId: String!
  orgId: String           # null for personal, set for org subscriptions
  maletId: String         # null for org plans, set for malet subscriptions
  planId: String!         # "starter", "pro", "enterprise"
  planName: String!
  status: SubscriptionStatus!  # ACTIVE, TRIALING, CANCELED, PAST_DUE, EXPIRED
  billingInterval: BillingInterval!  # MONTHLY, YEARLY
  stripeSubscriptionId: String
  stripeCustomerId: String
  priceAmount: Int!       # Amount in cents
  currency: String!
  currentPeriodStart: DateTime!
  currentPeriodEnd: DateTime!
}

Checkout Flows

Two checkout paths are supported:

1. Stripe Hosted Checkout (Redirect)

mutation {
  createOrgCheckoutSession(
    orgId: "org_123", userId: "user_456",
    planId: "pro", planName: "Pro",
    stripePriceId: "price_xxx",
    billingInterval: MONTHLY,
    successUrl: "https://...", cancelUrl: "https://..."
  ) { sessionId url }
}

Redirects user to Stripe's hosted checkout page.

2. In-App Stripe Elements (Embedded)

# Step 1: Create SetupIntent
mutation {
  createSubscriptionSetupIntent(
    userId: "user_456", planId: "pro", planName: "Pro",
    orgId: "org_123"
  ) { clientSecret customerId }
}

# Step 2: Confirm after Stripe Elements collects card
mutation {
  confirmSubscription(
    userId: "user_456", customerId: "cus_xxx",
    setupIntentId: "seti_xxx", stripePriceId: "price_xxx",
    planId: "pro", planName: "Pro",
    billingInterval: MONTHLY, orgId: "org_123"
  ) { id status planId }
}

TCP Tier Enforcement

The SubscriptionTcpController exposes a TCP microservice that other subgraphs query to enforce billing limits. This is the platform's cross-subgraph billing gate.

`get_active_tier` (MessagePattern โ€” RPC)

// Producer (malets, organizations):
const { tier, limits } = await firstValueFrom(
  this.paymentsClient.send('get_active_tier', { userId, orgId })
);

// Response:
{ tier: 'pro', limits: { maxMalets: 5, maxMembers: 10 } }

Resolution logic:

  1. Query BillingSubscription with status: ACTIVE/TRIALING
  2. Prefer orgId if provided, fallback to userId
  3. Return latest subscription's tier, or default to starter

`bootstrap_org_subscription` (EventPattern โ€” Fire-and-Forget)

Created automatically when an organization is created:

// Producer (organizations):
this.paymentsClient.emit('bootstrap_org_subscription', { orgId, userId });

// Consumer (payments):
// Creates BillingSubscription { planId: 'starter', status: ACTIVE, priceAmount: 0 }
// Idempotent โ€” skips if active subscription already exists

Fail-Open Strategy

If the TCP connection to payments times out (3-second deadline), consumers apply a fail-open downgrade:

  • Default to Starter tier limits
  • Free-tier users: unaffected (can access basic limits)
  • Pro/Enterprise users: temporarily restricted to Starter limits
  • No data corruption โ€” all state remains consistent

Tier Limits (Shared Config)

// libs/common/src/config/tier-limits.ts
export const TIER_LIMITS = {
  starter:    { maxMalets: 1,        maxMembers: 3          },
  pro:        { maxMalets: 5,        maxMembers: 10         },
  enterprise: { maxMalets: Infinity, maxMembers: Infinity   },
};

Both the frontend (src/lib/config/plans.ts) and backend use these values. Both files must be updated simultaneously when adjusting tier limits.


Webhook Architecture

Stripe

  • Uses manual capture (auth-then-capture)
  • Events: payment_intent.succeeded, payment_intent.payment_failed, payment_intent.canceled
  • Verification: stripe-signature header via Stripe's SDK

Paystack

  • Uses auto-capture (charges are instantly captured)
  • Events: charge.success, charge.failed, refund.processed
  • Verification: HMAC-SHA512 against x-paystack-signature header

Event Emission

When a webhook successfully verifies a captured transaction:

  1. Transaction status updated to CAPTURED
  2. payment_captured event emitted via TCP
  3. The services subgraph listens (for booking confirmations) and murchases finalizes the order

Refunding Mechanics

Both full and partial refunds are supported:

Partial Refunds

  • partialRefundPayment accepts an amount against a CAPTURED transaction
  • refundedAmount tracked incrementally
  • Status: PARTIALLY_REFUNDED while partial; auto-transitions to REFUNDED when refundedAmount == totalAmount
  • Over-refunding is prevented by the platform

Connected Accounts (Seller Payouts)

The ConnectedAccountResolver manages Stripe Connect accounts for Malet Owners to receive payouts:

mutation { createConnectedAccount(maletId: "...", email: "...") { accountId onboardingUrl } }
query { connectedAccount(maletId: "...") { accountId chargesEnabled payoutsEnabled } }

Sellers complete Stripe's hosted onboarding flow to verify their identity and link a bank account.


Vertical Use Cases

Different Malet Verticals leverage the Payments API in unique ways:

  • Restaurant Vertical: Multiple concurrent AUTHORIZED holds for "Split Bill" scenarios
  • Tour Operator Vertical: CAPTURED deposit upon booking, scheduled final balance hold via Stripe 60 days before the itinerary
  • Retail Storefronts: "Authorize Checkout โ†’ Capture on Shipping" to prevent chargebacks on unfulfilled goods

Organization Membership Security

All org-scoped billing endpoints are guarded by a two-layer authorization chain:

Request โ†’ GqlAuthGuard โ†’ OrgMembershipGuard โ†’ Resolver

GqlAuthGuard (Layer 1)

Verifies the request is authenticated by checking the user header (JSON-serialized by the gateway). Rejects with Unauthorized if missing.

OrgMembershipGuard (Layer 2)

Verifies the authenticated user is a member of the requested organization. The guard:

  1. Extracts orgId from the GraphQL arguments (supports top-level and nested input.orgId)
  2. Queries the shared memberships MongoDB collection directly via mongoose.connection.collection('memberships')
  3. Looks up a document matching { userId, organizationId: orgId }
  4. Throws ForbiddenException('Not a member of this organization') if no match
  5. Skips for non-org-scoped queries (no orgId in args)

IMPORTANT

The guard queries the shared MongoDB memberships collection directly โ€” no TCP call or gateway header required. This is possible because all NestJS subgraphs share the same DBModule connection.

Location: libs/common/src/guards/org-membership.guard.ts

Protected Endpoints

Endpoint Type Guard Stack
orgSubscription(orgId) Query GqlAuthGuard + OrgMembershipGuard
orgInvoices(orgId) Query GqlAuthGuard + OrgMembershipGuard
orgPaymentMethods(orgId) Query GqlAuthGuard + OrgMembershipGuard
createOrgCheckoutSession(orgId) Mutation GqlAuthGuard + OrgMembershipGuard
createOrgSetupCheckout(orgId) Mutation GqlAuthGuard + OrgMembershipGuard
cancelOrgSubscription(orgId) Mutation GqlAuthGuard + OrgMembershipGuard
removePaymentMethod(orgId) Mutation GqlAuthGuard + OrgMembershipGuard
setDefaultPaymentMethod(orgId) Mutation GqlAuthGuard + OrgMembershipGuard

E2E Guard Tests

15 E2E tests in apps/payments/test/billing-guards.e2e-spec.ts exercise the full guard chain:

  • Unauthenticated requests โ†’ rejected with "Unauthorized"
  • Authenticated non-members โ†’ rejected with "Not a member of this organization"
  • Authenticated members โ†’ allowed through to resolver
  • User-scoped queries (userSubscription) โ†’ membership check NOT triggered

Payment Method Lifecycle

Org admins can manage payment methods attached to their organization's Stripe Customer:

Remove Payment Method

mutation {
  removePaymentMethod(orgId: "org_123", paymentMethodId: "pm_xxx")
}

Safety guards:

  • Cannot remove the default payment method (Stripe requires at least one default for active subscriptions)
  • Cannot remove the last payment method if an active subscription exists
  • Returns true on success

Set Default Payment Method

mutation {
  setDefaultPaymentMethod(orgId: "org_123", paymentMethodId: "pm_xxx") {
    id brand last4 expMonth expYear isDefault
  }
}

Updates the Stripe Customer's invoice_settings.default_payment_method.

Card Limit Enforcement

A maximum of 5 payment methods per organization is enforced in createOrgSetupCheckout. Attempting to add a 6th card returns a user-friendly error.


GraphQL API Reference

Queries

# Transactions
transaction(id: ID!): Transaction
maletTransactions(maletId: ID!): [Transaction!]!

# Subscriptions (Malet-scoped)
userSubscription(userId: ID!, maletId: ID!): BillingSubscription
maletSubscriptions(maletId: ID!): [BillingSubscription!]!
hasActiveSubscription(userId: ID!, maletId: ID!): Boolean!

# Subscriptions (Org-scoped)
orgSubscription(orgId: ID!): BillingSubscription
orgInvoices(orgId: ID!, limit: Int): [InvoiceRecord!]!
orgPaymentMethods(orgId: ID!): [PaymentMethodRecord!]!

# Connected Accounts
connectedAccount(maletId: ID!): ConnectedAccount

Mutations

# Transactions
createPaymentIntent(input: CreatePaymentIntentInput!): PaymentIntentResult!
createCryptoPaymentIntent(input: CreatePaymentIntentInput!): PaymentIntentResult!
authorizePayment(input: CreateTransactionInput!): Transaction!
voidPayment(transactionId: ID!): Transaction!
partialRefundPayment(input: PartialRefundInput!): Transaction!

# Subscriptions (Malet-scoped)
createSubscriptionCheckout(...): CheckoutSessionResult!
cancelSubscription(subscriptionId: ID!, immediate: Boolean): BillingSubscription!

# Subscriptions (Org-scoped)
createOrgCheckoutSession(...): CheckoutSessionResult!
cancelOrgSubscription(orgId: ID!, immediate: Boolean): BillingSubscription!

# Payment Method Management (Org-scoped)
removePaymentMethod(orgId: ID!, paymentMethodId: ID!): Boolean!
setDefaultPaymentMethod(orgId: ID!, paymentMethodId: ID!): PaymentMethodRecord!

# In-App Checkout (Stripe Elements)
createSubscriptionSetupIntent(userId: ID!, planId: String!, planName: String!, orgId: ID): SetupIntentResult!
confirmSubscription(...): BillingSubscription!

# Connected Accounts
createConnectedAccount(maletId: ID!, email: String!): ConnectedAccountResult!

Cross-Service Dependencies

Service Integration
organizations bootstrap_org_subscription event consumer, get_active_tier RPC provider
malets get_active_tier RPC provider (Malet creation limit)
murchases payment_captured event โ†’ order finalization
services payment_captured event โ†’ booking confirmation
alerts Subscription status change notifications
gateway Stripe webhook endpoint at /webhooks/stripe

Environment Variables

Variable Description
STRIPE_SECRET_KEY Stripe API secret key
STRIPE_WEBHOOK_SECRET Stripe webhook endpoint signing secret
STRIPE_CONNECT_CLIENT_ID Stripe Connect OAuth client ID
PAYSTACK_SECRET_KEY Paystack API secret key
PAYSTACK_WEBHOOK_SECRET Paystack webhook signing secret