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_accountscollections)
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
Transactionmodel includescryptoNetwork,cryptoTxHash, andwalletAddress(optional fields) when a payment is made via crypto. - Payment Intent: A dedicated
createCryptoPaymentIntentmutation delegates toStripeHandlerand enforcespayment_method_types: ['crypto']. - Webhook Reconciliation: The
WebhookServiceautomatically extracts crypto metadata (network, transaction hash) frompayment_intent.succeededevents. - 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:
- Query
BillingSubscriptionwithstatus: ACTIVE/TRIALING - Prefer
orgIdif provided, fallback touserId - 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-signatureheader 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-signatureheader
Event Emission
When a webhook successfully verifies a captured transaction:
- Transaction status updated to
CAPTURED payment_capturedevent emitted via TCP- The
servicessubgraph listens (for booking confirmations) andmurchasesfinalizes the order
Refunding Mechanics
Both full and partial refunds are supported:
Partial Refunds
partialRefundPaymentaccepts anamountagainst aCAPTUREDtransactionrefundedAmounttracked incrementally- Status:
PARTIALLY_REFUNDEDwhile partial; auto-transitions toREFUNDEDwhenrefundedAmount == 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
AUTHORIZEDholds for "Split Bill" scenarios - Tour Operator Vertical:
CAPTUREDdeposit 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:
- Extracts
orgIdfrom the GraphQL arguments (supports top-level and nestedinput.orgId) - Queries the shared
membershipsMongoDB collection directly viamongoose.connection.collection('memberships') - Looks up a document matching
{ userId, organizationId: orgId } - Throws
ForbiddenException('Not a member of this organization')if no match - Skips for non-org-scoped queries (no
orgIdin 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
trueon 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 |
Related
- Subscription & Billing Architecture โ TCP tier enforcement, fail-open strategy, frontend/backend config parity
- Org Subscription Bootstrap โ Auto-Starter provisioning on org creation
- Organizations Subgraph โ Deletion safety guards that depend on
get_active_tier - Org Context & Tier Access โ Frontend
canCreateOrganization()gate - Revenue Sharing & Payouts โ Platform commission, Stripe Connect payouts
- Payments History UI โ Frontend invoice display and billing page architecture
- Stripe Invoice Integration โ End-to-end invoice data flow and UI behavior matrix
- Stablecoin Commerce โ Architecture for USDC stablecoin payments via Stripe
- Privacy & Security APIs โ Platform-wide security event log and session management