/** * `oxy.billing` — the signed-in user's subscription, wallet and payment history. * * Every call reads the SIGNED-IN user's data unless a `userId` is passed (the * API refuses any other user's). Checkout, plan changes and cancellation live * under `/billing` (Stripe) and are not wrapped here yet. * * The types model the JSON these routes actually return (`subscription.controller`, * `payment.controller`, `wallet.routes` in `packages/api`); `Date` columns * arrive as ISO-8601 strings. */ import type { PersonalPlanCatalogue, PersonalPlanCheckoutRequest, PersonalPlanCheckoutResult, ProductPlanCatalogue, ProductPlanCheckoutRequest, ProductSubscriptionCancellationResult, ProductSubscriptionRenewalRequest, ProductSubscriptionSummary, SubjectProductAccess, SubjectProductAccessQuery, SubscriptionCreditGrant, } from '@oxy.so/contracts'; import { cancelProductSubscriptionCheck, parseContract, personalPlanCatalogueCheck, personalPlanCheckoutRequestCheck, personalPlanCheckoutResultCheck, productPlanCatalogueCheck, productPlanCheckoutRequestCheck, productSubscriptionCancellationResultCheck, productSubscriptionRenewalRequestCheck, productSubscriptionsResponseCheck, subjectProductAccessCheck, subjectProductAccessQueryCheck, subscriptionCreditGrantsResponseCheck, } from '@oxy.so/contracts/client'; import type { OxyContext } from '../client/context'; const SUBSCRIPTION_TTL = 2 * 60 * 1000; const WALLET_TTL = 60 * 1000; /** Subscription tier. */ export type SubscriptionPlan = 'basic' | 'pro' | 'business'; /** Lifecycle state of a subscription. */ export type SubscriptionStatus = 'active' | 'canceled' | 'expired'; /** Feature flags toggled by the active plan. */ export interface SubscriptionFeatures { analytics: boolean; premiumBadge: boolean; unlimitedFollowing: boolean; higherUploadLimits: boolean; promotedPosts: boolean; businessTools: boolean; } /** * A user's subscription. A user who never subscribed gets `{ plan: 'basic' }`, * so every other field is optional. */ export interface Subscription { plan: SubscriptionPlan; status?: SubscriptionStatus; userId?: string; startDate?: string; endDate?: string; autoRenew?: boolean; paymentMethod?: string; latestInvoice?: string; features?: SubscriptionFeatures; createdAt?: string; updatedAt?: string; } /** A payment history entry (a `deposit` or `purchase` transaction). */ export interface Payment { id: string; userId: string; type: string; amount: number; status: string; description?: string; itemId?: string; itemType?: string; /** When the payment was created. */ timestamp?: string; completedAt?: string; } /** A user's FairCoin wallet. */ export interface Wallet { userId: string; balance: number; /** On-chain withdrawal address, `null` until one is set. */ address: string | null; } export type WalletTransactionType = 'deposit' | 'withdrawal' | 'transfer' | 'purchase'; export type WalletTransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled'; /** One wallet ledger entry. */ export interface WalletTransaction { id: string; userId: string | { _id: string; username?: string }; type: WalletTransactionType; amount: number; status: WalletTransactionStatus; description?: string; recipientId?: string | { _id: string; username?: string } | null; itemId?: string; itemType?: string; timestamp?: string; completedAt?: string; } export interface WalletPagination { total: number; limit: number; offset: number; hasMore: boolean; } /** A page of wallet transactions. */ export interface WalletTransactionsPage { data: WalletTransaction[]; pagination: WalletPagination; } export class BillingApi { constructor(protected readonly ctx: OxyContext) {} async renewProductSubscription( request: ProductSubscriptionRenewalRequest, ): Promise { return parseContract( personalPlanCheckoutResultCheck, await this.ctx.request( 'POST', '/billing/product-subscriptions/renew', parseContract(productSubscriptionRenewalRequestCheck, request), { cache: false }, ), ); } async startProductPlanCheckout( request: ProductPlanCheckoutRequest, ): Promise { return parseContract( personalPlanCheckoutResultCheck, await this.ctx.request( 'POST', '/billing/checkout/product-plan', parseContract(productPlanCheckoutRequestCheck, request), { cache: false }, ), ); } async productPlans(productId: string): Promise { return parseContract( productPlanCatalogueCheck, await this.ctx.request( 'GET', `/billing/product-plans/${encodeURIComponent(productId)}`, undefined, { cache: false }, ), ); } async startPersonalPlanCheckout( request: PersonalPlanCheckoutRequest, ): Promise { return parseContract( personalPlanCheckoutResultCheck, await this.ctx.request( 'POST', '/billing/checkout/personal-plan', parseContract(personalPlanCheckoutRequestCheck, request), { cache: false }, ), ); } /** Public versioned personal plans. Discovery never proves customer access. */ async personalPlans(): Promise { return parseContract( personalPlanCatalogueCheck, await this.ctx.request('GET', '/billing/personal-plans', undefined, { cache: false, }), ); } /** Product rights only; requires a production application-bound user session. Never cached. */ async productAccess(query: SubjectProductAccessQuery): Promise { const parsed = parseContract(subjectProductAccessQueryCheck, query); const response = await this.ctx.request( 'GET', `/v1/products/${encodeURIComponent(parsed.productId)}/access/${encodeURIComponent(parsed.subjectAccountId)}`, undefined, { cache: false }, ); return parseContract(subjectProductAccessCheck, response); } /** Named commercial sources for the current subject; no inferred plan collapse. */ async productSubscriptions( expectedSubjectAccountId?: string, ): Promise { return parseContract( productSubscriptionsResponseCheck, await this.ctx.request( 'GET', expectedSubjectAccountId ? `/billing/product-subscriptions?expectedSubjectAccountId=${encodeURIComponent(expectedSubjectAccountId)}` : '/billing/product-subscriptions', undefined, { cache: false }, ), ).subscriptions; } /** Per-grant credit provenance; no interpretation of the historical mixed balance. */ async creditGrants(): Promise { return parseContract( subscriptionCreditGrantsResponseCheck, await this.ctx.request('GET', '/billing/credit-grants', undefined, { cache: false }), ).grants; } /** Payer-only named-source cancellation. Resolves on provider acceptance, including pending local reconciliation. Use cancelProductSubscriptionWithStatus for detailed completion state. */ async cancelProductSubscription( sourceId: string, expectedSubjectAccountId?: string, actionId?: string, ): Promise { await this.ctx.request( 'POST', '/billing/product-subscriptions/cancel', parseContract(cancelProductSubscriptionCheck, { sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}), }), { cache: false }, ); } /** Provider acceptance versus completed local cancellation, for user-facing flows. */ async cancelProductSubscriptionWithStatus( sourceId: string, expectedSubjectAccountId?: string, actionId?: string, ): Promise { return parseContract( productSubscriptionCancellationResultCheck, await this.ctx.request( 'POST', '/billing/product-subscriptions/cancel', parseContract(cancelProductSubscriptionCheck, { sourceId, ...(actionId ? { actionId } : {}), ...(expectedSubjectAccountId ? { expectedSubjectAccountId } : {}), }), { cache: false }, ), ); } /** The signed-in user's payment history. Never cached. */ async payments(): Promise { return this.ctx.request('GET', '/payments/user', undefined, { cache: false }); } /** A user's subscription (default: the signed-in user's). */ async subscription(userId?: string): Promise { const id = this.resolveUserId(userId); return this.ctx.request('GET', `/subscription/${id}`, undefined, { cache: true, cacheTTL: SUBSCRIPTION_TTL, }); } /** A user's wallet (default: the signed-in user's). Cached briefly: the balance moves. */ async wallet(userId?: string): Promise { const id = this.resolveUserId(userId); return this.ctx.request('GET', `/wallet/${id}`, undefined, { cache: true, cacheTTL: WALLET_TTL, }); } /** A page of a user's wallet transactions (default: the signed-in user's). Never cached. */ async walletTransactions( options: { userId?: string; limit?: number; offset?: number } = {}, ): Promise { const id = this.resolveUserId(options.userId); const params: Record = {}; if (options.limit) params.limit = options.limit; if (options.offset) params.offset = options.offset; return this.ctx.request('GET', `/wallet/transactions/${id}`, params, { cache: false, }); } private resolveUserId(userId?: string): string { const id = userId || this.ctx.oxy.session.userId; if (!id) throw new Error('User not authenticated'); return id; } }