From 4e0ed6477775d66d39a450c2582137ef0c536fec Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Mon, 11 May 2026 13:50:08 +0100 Subject: [PATCH 1/4] feat: subscriptions test flow --- apps/api/src/modules/Subscription.ts | 50 ++ apps/api/src/modules/chains/Solana.ts | 536 ++++++++++++++++++ apps/api/src/routes/index.ts | 3 +- apps/api/src/routes/subscriptions.routes.ts | 106 ++++ apps/programs/src/lib.rs | 375 ++++++++++++ apps/web/src/app/app.routes.ts | 7 + apps/web/src/app/core/services/index.ts | 1 + .../src/app/core/services/wallet.service.ts | 94 +++ apps/web/src/app/data/services/index.ts | 1 + .../data/services/subscriptions.service.ts | 38 ++ .../features/checkout/checkout.component.html | 15 + .../features/checkout/checkout.component.scss | 18 + .../features/checkout/checkout.component.ts | 91 +++ apps/web/src/app/features/checkout/index.ts | 1 + package-lock.json | 22 + package.json | 1 + 16 files changed, 1358 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/modules/Subscription.ts create mode 100644 apps/api/src/routes/subscriptions.routes.ts create mode 100644 apps/programs/src/lib.rs create mode 100644 apps/web/src/app/core/services/wallet.service.ts create mode 100644 apps/web/src/app/data/services/subscriptions.service.ts create mode 100644 apps/web/src/app/features/checkout/checkout.component.html create mode 100644 apps/web/src/app/features/checkout/checkout.component.scss create mode 100644 apps/web/src/app/features/checkout/checkout.component.ts create mode 100644 apps/web/src/app/features/checkout/index.ts diff --git a/apps/api/src/modules/Subscription.ts b/apps/api/src/modules/Subscription.ts new file mode 100644 index 0000000..a890ff6 --- /dev/null +++ b/apps/api/src/modules/Subscription.ts @@ -0,0 +1,50 @@ +/** + * @fileOverview Methods for Subscriptions + * + * + * @module Subscription + */ + +import { Solana } from './chains/Solana'; + +export class SubscriptionModule { + constructor() { + + } + + async CreateSubscription(subscriberPublicKey: string, amount: number, periodSeconds: number): Promise { + const solana = new Solana(); + const result = await solana.CreateSubscription(subscriberPublicKey, amount, periodSeconds); + return result; + } + + async GetSubscription(subscriberPublicKey: string): Promise { + const solana = new Solana(); + const result = await solana.GetSubscription(subscriberPublicKey); + return result; + } + + async CancelSubscription(subscriberPublicKey: string): Promise { + const solana = new Solana(); + const result = await solana.CancelSubscription(subscriberPublicKey); + return result; + } + + async ChargeSubscription( + subscriberPublicKey: string, + feePayerPublicKey: string + ): Promise { + const solana = new Solana(); + const result = await solana.ChargeSubscription( + subscriberPublicKey, + feePayerPublicKey + ); + return result; + } + + async GetSubscriptionDebugInfo(subscriberPublicKey: string): Promise { + const solana = new Solana(); + const result = await solana.GetSubscriptionDebugInfo(subscriberPublicKey); + return result; + } +} diff --git a/apps/api/src/modules/chains/Solana.ts b/apps/api/src/modules/chains/Solana.ts index f6b030c..4192779 100644 --- a/apps/api/src/modules/chains/Solana.ts +++ b/apps/api/src/modules/chains/Solana.ts @@ -12,14 +12,18 @@ import { Cluster, PublicKey, Transaction, + TransactionInstruction, + SystemProgram, ParsedTransactionWithMeta, ConfirmedSignatureInfo, } from '@solana/web3.js'; +import { createHash } from 'crypto'; import bs58 from 'bs58'; import { getAssociatedTokenAddress, createTransferInstruction, createAssociatedTokenAccountIdempotentInstruction, + createApproveCheckedInstruction, TOKEN_PROGRAM_ID, } from '@solana/spl-token'; @@ -577,4 +581,536 @@ export class Solana { return null; } + + async CreateSubscription( + subscriberPublicKey: string, + amount: number, + periodSeconds: number, + ): Promise<{ + unsigned_transaction: string; + estimated_fee_lamports: number; + blockhash: string; + last_valid_block_height: number; + subscription_pda: string; + amount_cents: number; + amount_atomic: number; + period_seconds: number; + }> { + if (!subscriberPublicKey) { + throw new Error('Subscriber public key is required'); + } + + if (!Number.isFinite(amount) || amount <= 0) { + throw new Error('Amount must be a positive number'); + } + + const subscriberPubkey = new PublicKey(subscriberPublicKey); + if (amount <= 0) { + throw new Error('Amount is too small after rounding to cents'); + } + + // Input amount is expected in USD (e.g. 0.01), then converted: + // dollars -> cents -> USDC atomic units (6 decimals => 1 cent = 10_000 units) + const chargeAmountAtomic = amount * 10_000; + + // Approve for 60 months (60 times monthly price) + const approvedAmountAtomic = chargeAmountAtomic * 60; + + // Combined one-signature flow: + // 1) Approve delegate (authority PDA) to spend subscription amount + // 2) Create subscription PDA in your program + const approveDelegateInstruction = await this.ApproveDelegateInstruction( + subscriberPubkey, + approvedAmountAtomic + ); + const createSubscriptionInstruction = this.CreateSubscriptionInstruction( + subscriberPubkey, + chargeAmountAtomic, + periodSeconds + ); + + const transaction = new Transaction() + .add(approveDelegateInstruction) + .add(createSubscriptionInstruction); + + const { blockhash, lastValidBlockHeight } = await this.WithRetry(() => + this.connection.getLatestBlockhash('confirmed') + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = subscriberPubkey; + + const fee = await this.WithRetry(() => + this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + ); + + const serializedTransaction = transaction + .serialize({ requireAllSignatures: false }) + .toString('base64'); + + return { + unsigned_transaction: serializedTransaction, + estimated_fee_lamports: fee.value || 0, + blockhash, + last_valid_block_height: lastValidBlockHeight, + subscription_pda: this.GetSubscriptionPda(subscriberPubkey).toBase58(), + amount_cents: amount, + amount_atomic: chargeAmountAtomic, + period_seconds: periodSeconds, + }; + } + + private GetProgramId(): PublicKey { + return new PublicKey('9JAVCcRBhZz4Jjx1qMdCgZj7bzEkVE5aMSeHwM94278F'); + } + + private GetMerchantPubkey(): PublicKey { + const MERCHANT_PUBLIC_KEY = "HxXyrNpFT6oioH2whDC3oCu54auNcQUhSwh6yX48EsEJ"; + return new PublicKey(MERCHANT_PUBLIC_KEY); + } + + private GetSubscriptionPda(subscriberPubkey: PublicKey): PublicKey { + const [subscriptionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from('subscription'), + subscriberPubkey.toBuffer(), + this.GetMerchantPubkey().toBuffer(), + ], + this.GetProgramId() + ); + return subscriptionPda; + } + + private GetAuthorityPda(): PublicKey { + const [authorityPda] = PublicKey.findProgramAddressSync( + [Buffer.from('authority')], + this.GetProgramId() + ); + return authorityPda; + } + + private async ApproveDelegateInstruction( + subscriberPubkey: PublicKey, + amountAtomic: number + ): Promise { + const mintPubkey = new PublicKey(this.GetUSDCMintAddress()); + const subscriberTokenAccount = await getAssociatedTokenAddress( + mintPubkey, + subscriberPubkey + ); + + return createApproveCheckedInstruction( + subscriberTokenAccount, + mintPubkey, + this.GetAuthorityPda(), + subscriberPubkey, + amountAtomic, + 6 + ); + } + + private CreateSubscriptionInstruction( + subscriberPubkey: PublicKey, + amountAtomic: number, + periodSeconds: number + ): TransactionInstruction { + const mintPubkey = new PublicKey(this.GetUSDCMintAddress()); + const amountBuffer = Buffer.alloc(8); + amountBuffer.writeBigUInt64LE(BigInt(amountAtomic)); + + const periodBuffer = Buffer.alloc(8); + periodBuffer.writeBigInt64LE(BigInt(periodSeconds)); + + const discriminator = createHash('sha256') + .update('global:create_subscription') + .digest() + .subarray(0, 8); + + const instructionData = Buffer.concat([ + discriminator, + amountBuffer, + periodBuffer, + ]); + + return new TransactionInstruction({ + programId: this.GetProgramId(), + keys: [ + { pubkey: subscriberPubkey, isSigner: true, isWritable: true }, + { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { + pubkey: this.GetSubscriptionPda(subscriberPubkey), + isSigner: false, + isWritable: true, + }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + ], + data: instructionData, + }); + } + + async GetSubscription(subscriberPublicKey: string): Promise<{ + exists: boolean; + subscription_pda: string; + subscriber?: string; + merchant?: string; + mint?: string; + amount_atomic?: string; + amount_ui?: number; + period_seconds?: number; + next_charge_at?: number; + status?: 'active' | 'paused' | 'unknown'; + bump?: number; + }> { + if (!subscriberPublicKey) { + throw new Error('Subscriber public key is required'); + } + + const programId = new PublicKey('9JAVCcRBhZz4Jjx1qMdCgZj7bzEkVE5aMSeHwM94278F'); + const merchantPubkey = new PublicKey( + 'HxXyrNpFT6oioH2whDC3oCu54auNcQUhSwh6yX48EsEJ' + ); + const subscriberPubkey = new PublicKey(subscriberPublicKey); + + const [subscriptionPda] = PublicKey.findProgramAddressSync( + [ + Buffer.from('subscription'), + subscriberPubkey.toBuffer(), + merchantPubkey.toBuffer(), + ], + programId + ); + + const accountInfo = await this.WithRetry(() => + this.connection.getAccountInfo(subscriptionPda, 'confirmed') + ); + + if (!accountInfo) { + return { + exists: false, + subscription_pda: subscriptionPda.toBase58(), + }; + } + + if (!accountInfo.owner.equals(programId)) { + throw new Error('Subscription PDA exists but is not owned by the program'); + } + + const data = accountInfo.data; + const minimumExpectedDataLength = 8 + 32 + 32 + 32 + 8 + 8 + 8 + 1 + 1; + if (data.length < minimumExpectedDataLength) { + throw new Error('Subscription account data is too short'); + } + + let offset = 8; // Skip account discriminator + + const subscriber = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); + offset += 32; + const merchant = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); + offset += 32; + const mint = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); + offset += 32; + + const amountAtomic = data.readBigUInt64LE(offset); + offset += 8; + + const periodSeconds = Number(data.readBigInt64LE(offset)); + offset += 8; + + const nextChargeAt = Number(data.readBigInt64LE(offset)); + offset += 8; + + const statusRaw = data.readUInt8(offset); + offset += 1; + + const bump = data.readUInt8(offset); + + const status = + statusRaw === 0 ? 'active' : statusRaw === 1 ? 'paused' : 'unknown'; + + return { + exists: true, + subscription_pda: subscriptionPda.toBase58(), + subscriber, + merchant, + mint, + amount_atomic: amountAtomic.toString(), + amount_ui: Number(amountAtomic) / 1_000_000, + period_seconds: periodSeconds, + next_charge_at: nextChargeAt, + status, + bump, + }; + } + + async CancelSubscription(subscriberPublicKey: string): Promise<{ + unsigned_transaction: string; + estimated_fee_lamports: number; + blockhash: string; + last_valid_block_height: number; + subscription_pda: string; + }> { + if (!subscriberPublicKey) { + throw new Error('Subscriber public key is required'); + } + + const subscriberPubkey = new PublicKey(subscriberPublicKey); + const cancelInstruction = this.CancelSubscriptionInstruction(subscriberPubkey); + + const transaction = new Transaction().add(cancelInstruction); + const { blockhash, lastValidBlockHeight } = await this.WithRetry(() => + this.connection.getLatestBlockhash('confirmed') + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = subscriberPubkey; + + const fee = await this.WithRetry(() => + this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + ); + + const serializedTransaction = transaction + .serialize({ requireAllSignatures: false }) + .toString('base64'); + + return { + unsigned_transaction: serializedTransaction, + estimated_fee_lamports: fee.value || 0, + blockhash, + last_valid_block_height: lastValidBlockHeight, + subscription_pda: this.GetSubscriptionPda(subscriberPubkey).toBase58(), + }; + } + + async ChargeSubscription( + subscriberPublicKey: string, + feePayerPublicKey: string + ): Promise<{ + unsigned_transaction: string; + estimated_fee_lamports: number; + blockhash: string; + last_valid_block_height: number; + subscription_pda: string; + subscriber_token_account: string; + merchant_token_account: string; + }> { + if (!subscriberPublicKey) { + throw new Error('Subscriber public key is required'); + } + + if (!feePayerPublicKey) { + throw new Error('Fee payer public key is required'); + } + + const subscriberPubkey = new PublicKey(subscriberPublicKey); + const feePayerPubkey = new PublicKey(feePayerPublicKey); + + const mintPubkey = new PublicKey(this.GetUSDCMintAddress()); + const subscriberTokenAccount = await getAssociatedTokenAddress( + mintPubkey, + subscriberPubkey + ); + const merchantTokenAccount = await getAssociatedTokenAddress( + mintPubkey, + this.GetMerchantPubkey() + ); + + const chargeInstruction = this.ChargeSubscriptionInstruction( + subscriberPubkey, + subscriberTokenAccount, + merchantTokenAccount + ); + + const createMerchantTokenAccountInstruction = + createAssociatedTokenAccountIdempotentInstruction( + feePayerPubkey, + merchantTokenAccount, + this.GetMerchantPubkey(), + mintPubkey + ); + + const transaction = new Transaction() + .add(createMerchantTokenAccountInstruction) + .add(chargeInstruction); + const { blockhash, lastValidBlockHeight } = await this.WithRetry(() => + this.connection.getLatestBlockhash('confirmed') + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = feePayerPubkey; + + const fee = await this.WithRetry(() => + this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + ); + + const serializedTransaction = transaction + .serialize({ requireAllSignatures: false }) + .toString('base64'); + + return { + unsigned_transaction: serializedTransaction, + estimated_fee_lamports: fee.value || 0, + blockhash, + last_valid_block_height: lastValidBlockHeight, + subscription_pda: this.GetSubscriptionPda(subscriberPubkey).toBase58(), + subscriber_token_account: subscriberTokenAccount.toBase58(), + merchant_token_account: merchantTokenAccount.toBase58(), + }; + } + + async GetSubscriptionDebugInfo(subscriberPublicKey: string): Promise<{ + network: Cluster; + program_id: string; + authority_pda: string; + subscriber: { + public_key: string; + token_account: string; + token_account_exists: boolean; + amount_atomic: string | null; + amount_ui: number | null; + delegate: string | null; + delegated_amount_atomic: string | null; + }; + merchant: { + public_key: string; + token_account: string; + token_account_exists: boolean; + amount_atomic: string | null; + amount_ui: number | null; + }; + }> { + if (!subscriberPublicKey) { + throw new Error('Subscriber public key is required'); + } + + const subscriberPubkey = new PublicKey(subscriberPublicKey); + const merchantPubkey = this.GetMerchantPubkey(); + const mintPubkey = new PublicKey(this.GetUSDCMintAddress()); + + const subscriberTokenAccount = await getAssociatedTokenAddress( + mintPubkey, + subscriberPubkey + ); + const merchantTokenAccount = await getAssociatedTokenAddress( + mintPubkey, + merchantPubkey + ); + + const [subscriberInfo, merchantInfo] = await this.WithRetry(() => + this.connection.getMultipleAccountsInfo( + [subscriberTokenAccount, merchantTokenAccount], + 'confirmed' + ) + ); + + const parseTokenInfo = (accountInfo: Awaited>) => { + if (!accountInfo) { + return { + token_account_exists: false, + amount_atomic: null, + amount_ui: null, + delegate: null as string | null, + delegated_amount_atomic: null as string | null, + }; + } + + const parsed = accountInfo.data; + const amountAtomic = parsed.readBigUInt64LE(64).toString(); + const delegateOption = parsed.readUInt32LE(72); + const delegate = + delegateOption === 1 + ? new PublicKey(parsed.subarray(76, 108)).toBase58() + : null; + const delegatedAmountAtomic = + delegateOption === 1 ? parsed.readBigUInt64LE(121).toString() : null; + + return { + token_account_exists: true, + amount_atomic: amountAtomic, + amount_ui: Number(amountAtomic) / 1_000_000, + delegate, + delegated_amount_atomic: delegatedAmountAtomic, + }; + }; + + const subscriberParsed = parseTokenInfo(subscriberInfo); + const merchantParsed = parseTokenInfo(merchantInfo); + + return { + network: this.network, + program_id: this.GetProgramId().toBase58(), + authority_pda: this.GetAuthorityPda().toBase58(), + subscriber: { + public_key: subscriberPubkey.toBase58(), + token_account: subscriberTokenAccount.toBase58(), + token_account_exists: subscriberParsed.token_account_exists, + amount_atomic: subscriberParsed.amount_atomic, + amount_ui: subscriberParsed.amount_ui, + delegate: subscriberParsed.delegate, + delegated_amount_atomic: subscriberParsed.delegated_amount_atomic, + }, + merchant: { + public_key: merchantPubkey.toBase58(), + token_account: merchantTokenAccount.toBase58(), + token_account_exists: merchantParsed.token_account_exists, + amount_atomic: merchantParsed.amount_atomic, + amount_ui: merchantParsed.amount_ui, + }, + }; + } + + private CancelSubscriptionInstruction( + subscriberPubkey: PublicKey + ): TransactionInstruction { + const discriminator = createHash('sha256') + .update('global:cancel_subscription') + .digest() + .subarray(0, 8); + + return new TransactionInstruction({ + programId: this.GetProgramId(), + keys: [ + { + pubkey: this.GetSubscriptionPda(subscriberPubkey), + isSigner: false, + isWritable: true, + }, + { pubkey: subscriberPubkey, isSigner: false, isWritable: true }, + { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { pubkey: subscriberPubkey, isSigner: true, isWritable: false }, + ], + data: discriminator, + }); + } + + private ChargeSubscriptionInstruction( + subscriberPubkey: PublicKey, + subscriberTokenAccount: PublicKey, + merchantTokenAccount: PublicKey + ): TransactionInstruction { + const discriminator = createHash('sha256') + .update('global:charge_subscription') + .digest() + .subarray(0, 8); + + return new TransactionInstruction({ + programId: this.GetProgramId(), + keys: [ + { + pubkey: this.GetSubscriptionPda(subscriberPubkey), + isSigner: false, + isWritable: true, + }, + { pubkey: subscriberPubkey, isSigner: false, isWritable: false }, + { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { + pubkey: new PublicKey(this.GetUSDCMintAddress()), + isSigner: false, + isWritable: false, + }, + { pubkey: subscriberTokenAccount, isSigner: false, isWritable: true }, + { pubkey: merchantTokenAccount, isSigner: false, isWritable: true }, + { pubkey: this.GetAuthorityPda(), isSigner: false, isWritable: false }, + { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, + ], + data: discriminator, + }); + } } diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index c8bdc3d..1ec52b7 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -18,6 +18,7 @@ import eventsRouter from './events.routes'; import authExchangeRouter from './exchange.routes'; import configRouter from './config.routes'; import setupRouter from './setup.routes'; +import subscriptionsRouter from './subscriptions.routes'; const router = express.Router(); @@ -49,5 +50,5 @@ router.use('/topups', topupsRouter); router.use('/webhook_endpoints', webhookEndpointsRouter); router.use('/api_keys', apiKeysRouter); router.use('/events', eventsRouter); - +router.use('/subscriptions', subscriptionsRouter); export default router; diff --git a/apps/api/src/routes/subscriptions.routes.ts b/apps/api/src/routes/subscriptions.routes.ts new file mode 100644 index 0000000..44cdd7b --- /dev/null +++ b/apps/api/src/routes/subscriptions.routes.ts @@ -0,0 +1,106 @@ +import * as express from 'express'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { Logger } from '../utils/Logger'; +import { SubscriptionModule } from '../modules/Subscription'; + +const router = express.Router(); + +router.get( + '/:subscriberPublicKey', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const subscriberPublicKey = req.params.subscriberPublicKey; + + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.GetSubscription(subscriberPublicKey); + + Logger.info('Fetched subscription state', { + subscriberPublicKey, + exists: result?.exists, + subscriptionPda: result?.subscription_pda, + }); + + res.json(result); + }) +); + +router.post( + '/', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const accountId = req.user.account; + const subscriberPublicKey = req.body.subscriberPublicKey; + const amount = req.body.amount; + const periodSeconds = req.body.periodSeconds; + Logger.info('Subscription account', { accountId }); + + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.CreateSubscription(subscriberPublicKey, amount, periodSeconds); + + Logger.info('Subscription called'); + + res.json(result); + }) +); + +router.post( + '/cancel', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const subscriberPublicKey = req.body.subscriberPublicKey; + + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.CancelSubscription( + subscriberPublicKey + ); + + Logger.info('Built cancel subscription transaction', { + subscriberPublicKey, + subscriptionPda: result?.subscription_pda, + }); + + res.json(result); + }) +); + +router.post( + '/charge', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const subscriberPublicKey = req.body.subscriberPublicKey; + const feePayerPublicKey = req.body.feePayerPublicKey; + + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.ChargeSubscription( + subscriberPublicKey, + feePayerPublicKey + ); + + Logger.info('Built charge subscription transaction', { + subscriberPublicKey, + feePayerPublicKey, + subscriptionPda: result?.subscription_pda, + }); + + res.json(result); + }) +); + +router.get( + '/:subscriberPublicKey/debug', + AsyncHandler(async (req: express.Request, res: express.Response) => { + const subscriberPublicKey = req.params.subscriberPublicKey; + + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.GetSubscriptionDebugInfo( + subscriberPublicKey + ); + + Logger.info('Fetched subscription debug info', { + subscriberPublicKey, + subscriberTokenAccount: result?.subscriber?.token_account, + merchantTokenAccount: result?.merchant?.token_account, + delegate: result?.subscriber?.delegate, + }); + + res.json(result); + }) +); + +export default router; diff --git a/apps/programs/src/lib.rs b/apps/programs/src/lib.rs new file mode 100644 index 0000000..901db66 --- /dev/null +++ b/apps/programs/src/lib.rs @@ -0,0 +1,375 @@ +use anchor_lang::prelude::*; +use anchor_spl::token::{self, Mint, Token, TokenAccount, TransferChecked}; + +declare_id!("9JAVCcRBhZz4Jjx1qMdCgZj7bzEkVE5aMSeHwM94278F"); + +/// Hard upper bound on how long a subscription period may be. +/// 10 years in seconds — comfortably above any realistic billing cadence, +/// and small enough that `next_charge_at + period_seconds` can never +/// overflow `i64` even after centuries of cranking. +pub const MAX_PERIOD_SECONDS: i64 = 60 * 60 * 24 * 365 * 10; + +#[program] +pub mod subs { + use super::*; + + pub fn create_subscription( + ctx: Context, + amount: u64, + period_seconds: i64, + ) -> Result<()> { + require!(amount > 0, SubsError::InvalidAmount); + require!(period_seconds > 0, SubsError::InvalidPeriod); + require!( + period_seconds <= MAX_PERIOD_SECONDS, + SubsError::PeriodTooLong + ); + + let now = Clock::get()?.unix_timestamp; + let subscription = &mut ctx.accounts.subscription; + subscription.subscriber = ctx.accounts.subscriber.key(); + subscription.merchant = ctx.accounts.merchant.key(); + subscription.mint = ctx.accounts.mint.key(); + subscription.amount = amount; + subscription.period_seconds = period_seconds; + subscription.next_charge_at = now; + subscription.status = SubscriptionStatus::Active; + subscription.bump = ctx.bumps.subscription; + + emit!(SubscriptionCreated { + subscription: subscription.key(), + subscriber: subscription.subscriber, + merchant: subscription.merchant, + mint: subscription.mint, + amount: subscription.amount, + period_seconds: subscription.period_seconds, + next_charge_at: subscription.next_charge_at, + }); + + msg!("subscription created; first charge due immediately"); + Ok(()) + } + + pub fn charge_subscription(ctx: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + + require!( + ctx.accounts.subscription.status == SubscriptionStatus::Active, + SubsError::Paused + ); + require!( + now >= ctx.accounts.subscription.next_charge_at, + SubsError::NotDueYet + ); + + let amount = ctx.accounts.subscription.amount; + let new_next_charge_at = ctx + .accounts + .subscription + .next_charge_at + .checked_add(ctx.accounts.subscription.period_seconds) + .ok_or(SubsError::Overflow)?; + + ctx.accounts.subscription.next_charge_at = new_next_charge_at; + + let signer_seeds: &[&[&[u8]]] = &[&[b"authority", &[ctx.bumps.authority]]]; + + let cpi_accounts = TransferChecked { + from: ctx.accounts.subscriber_token.to_account_info(), + mint: ctx.accounts.mint.to_account_info(), + to: ctx.accounts.merchant_token.to_account_info(), + authority: ctx.accounts.authority.to_account_info(), + }; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + cpi_accounts, + signer_seeds, + ); + token::transfer_checked(cpi_ctx, amount, ctx.accounts.mint.decimals)?; + + emit!(SubscriptionCharged { + subscription: ctx.accounts.subscription.key(), + subscriber: ctx.accounts.subscription.subscriber, + merchant: ctx.accounts.subscription.merchant, + amount, + next_charge_at: new_next_charge_at, + }); + + msg!( + "charged {} (units); next due at {}", + amount, + new_next_charge_at + ); + Ok(()) + } + + pub fn cancel_subscription(ctx: Context) -> Result<()> { + emit!(SubscriptionCancelled { + subscription: ctx.accounts.subscription.key(), + by: ctx.accounts.signer.key(), + }); + msg!("subscription cancelled"); + Ok(()) + } + + pub fn pause_subscription(ctx: Context) -> Result<()> { + let signer_key = ctx.accounts.signer.key(); + let subscription = &mut ctx.accounts.subscription; + require!( + subscription.status == SubscriptionStatus::Active, + SubsError::AlreadyPaused + ); + subscription.status = SubscriptionStatus::Paused; + + emit!(SubscriptionPaused { + subscription: subscription.key(), + by: signer_key, + }); + msg!("subscription paused"); + Ok(()) + } + + pub fn resume_subscription(ctx: Context) -> Result<()> { + let now = Clock::get()?.unix_timestamp; + let signer_key = ctx.accounts.signer.key(); + let subscription = &mut ctx.accounts.subscription; + require!( + subscription.status == SubscriptionStatus::Paused, + SubsError::NotPaused + ); + + // Don't backcharge for the pause window. Push the next charge out by + // one full period from now, so the customer effectively gets a fresh + // billing cycle on resume. + subscription.next_charge_at = now + .checked_add(subscription.period_seconds) + .ok_or(SubsError::Overflow)?; + subscription.status = SubscriptionStatus::Active; + + emit!(SubscriptionResumed { + subscription: subscription.key(), + by: signer_key, + next_charge_at: subscription.next_charge_at, + }); + + msg!( + "subscription resumed; next charge at {}", + subscription.next_charge_at + ); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CreateSubscription<'info> { + #[account(mut)] + pub subscriber: Signer<'info>, + + /// CHECK: just an address recorded as the recipient + pub merchant: UncheckedAccount<'info>, + + pub mint: Account<'info, Mint>, + + #[account( + init, + payer = subscriber, + space = 8 + Subscription::INIT_SPACE, + seeds = [b"subscription", subscriber.key().as_ref(), merchant.key().as_ref()], + bump, + )] + pub subscription: Account<'info, Subscription>, + + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct ChargeSubscription<'info> { + #[account( + mut, + has_one = subscriber, + has_one = merchant, + has_one = mint, + seeds = [b"subscription", subscriber.key().as_ref(), merchant.key().as_ref()], + bump = subscription.bump, + )] + pub subscription: Account<'info, Subscription>, + + /// CHECK: validated by `has_one = subscriber` above + pub subscriber: UncheckedAccount<'info>, + + /// CHECK: validated by `has_one = merchant` above + pub merchant: UncheckedAccount<'info>, + + pub mint: Account<'info, Mint>, + + #[account( + mut, + token::mint = mint, + token::authority = subscriber, + )] + pub subscriber_token: Account<'info, TokenAccount>, + + #[account( + mut, + token::mint = mint, + token::authority = merchant, + )] + pub merchant_token: Account<'info, TokenAccount>, + + /// CHECK: program-wide delegate authority. The address is enforced by + /// the `seeds` constraint; we only use it to sign CPIs. + #[account(seeds = [b"authority"], bump)] + pub authority: UncheckedAccount<'info>, + + pub token_program: Program<'info, Token>, +} + +#[derive(Accounts)] +pub struct CancelSubscription<'info> { + #[account( + mut, + has_one = subscriber, + has_one = merchant, + seeds = [b"subscription", subscriber.key().as_ref(), merchant.key().as_ref()], + bump = subscription.bump, + close = subscriber, + )] + pub subscription: Account<'info, Subscription>, + + /// CHECK: validated by `has_one = subscriber` above; must be mut so + /// Anchor can refund rent lamports here when closing the PDA. + #[account(mut)] + pub subscriber: UncheckedAccount<'info>, + + /// CHECK: validated by `has_one = merchant` above + pub merchant: UncheckedAccount<'info>, + + /// Either the subscriber or the merchant may cancel. + #[account( + constraint = (signer.key() == subscriber.key() || signer.key() == merchant.key()) + @ SubsError::Unauthorized, + )] + pub signer: Signer<'info>, +} + +/// Used by both `pause_subscription` and `resume_subscription`. Same shape as +/// cancel but without the `close = subscriber` — pause/resume just mutate +/// state in place. +#[derive(Accounts)] +pub struct ChangeSubscriptionStatus<'info> { + #[account( + mut, + has_one = subscriber, + has_one = merchant, + seeds = [b"subscription", subscriber.key().as_ref(), merchant.key().as_ref()], + bump = subscription.bump, + )] + pub subscription: Account<'info, Subscription>, + + /// CHECK: validated by `has_one = subscriber` above + pub subscriber: UncheckedAccount<'info>, + + /// CHECK: validated by `has_one = merchant` above + pub merchant: UncheckedAccount<'info>, + + /// Either the subscriber or the merchant may pause/resume. + #[account( + constraint = (signer.key() == subscriber.key() || signer.key() == merchant.key()) + @ SubsError::Unauthorized, + )] + pub signer: Signer<'info>, +} + +#[account] +#[derive(InitSpace)] +pub struct Subscription { + pub subscriber: Pubkey, + pub merchant: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub period_seconds: i64, + pub next_charge_at: i64, + pub status: SubscriptionStatus, + pub bump: u8, + /// Reserved for future fields. When you add a new field, take the bytes + /// from this padding (e.g. add `paused_at: i64` and shrink to `[u8; 248]`). + /// Existing on-chain accounts will deserialize the new field as zero, + /// which is usually a sensible default. + pub padding: [u8; 256], +} + +#[derive(AnchorSerialize, AnchorDeserialize, Clone, Copy, PartialEq, Eq, InitSpace, Debug)] +pub enum SubscriptionStatus { + /// Charges run on schedule. Default state at creation. + Active, + /// Charges are suspended until `resume_subscription` is called. + /// State and history are preserved; only the crank is gated off. + Paused, +} + +/// Emitted by `create_subscription`. Contains everything an off-chain +/// indexer needs to record a new subscription without having to fetch the +/// PDA itself. +#[event] +pub struct SubscriptionCreated { + pub subscription: Pubkey, + pub subscriber: Pubkey, + pub merchant: Pubkey, + pub mint: Pubkey, + pub amount: u64, + pub period_seconds: i64, + pub next_charge_at: i64, +} + +/// Emitted by `charge_subscription`. +#[event] +pub struct SubscriptionCharged { + pub subscription: Pubkey, + pub subscriber: Pubkey, + pub merchant: Pubkey, + pub amount: u64, + pub next_charge_at: i64, +} + +/// Emitted by `cancel_subscription`. `by` is whichever party (subscriber or +/// merchant) signed the cancel. +#[event] +pub struct SubscriptionCancelled { + pub subscription: Pubkey, + pub by: Pubkey, +} + +#[event] +pub struct SubscriptionPaused { + pub subscription: Pubkey, + pub by: Pubkey, +} + +#[event] +pub struct SubscriptionResumed { + pub subscription: Pubkey, + pub by: Pubkey, + pub next_charge_at: i64, +} + +#[error_code] +pub enum SubsError { + #[msg("Subscription is not yet due to be charged")] + NotDueYet, + #[msg("Math overflow")] + Overflow, + #[msg("Only the subscriber or merchant can perform this action")] + Unauthorized, + #[msg("Subscription amount must be greater than zero")] + InvalidAmount, + #[msg("Subscription period must be greater than zero")] + InvalidPeriod, + #[msg("Subscription period exceeds the maximum allowed (10 years)")] + PeriodTooLong, + #[msg("Subscription is paused; resume it before charging")] + Paused, + #[msg("Subscription is already paused")] + AlreadyPaused, + #[msg("Subscription is not paused")] + NotPaused, +} diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index ef38863..847f892 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -47,6 +47,13 @@ export const routes: Routes = [ import('./features/session-expired/session-expired.component').then( (mod) => mod.SessionExpiredComponent ), + }, + { + path: 'checkout', + loadComponent: () => + import('./features/checkout/checkout.component').then( + (mod) => mod.CheckoutComponent + ), }, { path: '', diff --git a/apps/web/src/app/core/services/index.ts b/apps/web/src/app/core/services/index.ts index 07f6e07..3adae41 100644 --- a/apps/web/src/app/core/services/index.ts +++ b/apps/web/src/app/core/services/index.ts @@ -2,3 +2,4 @@ export * from './api.service'; export * from './auth.service'; export * from './meta.service'; export * from './storage.service'; +export * from './wallet.service'; diff --git a/apps/web/src/app/core/services/wallet.service.ts b/apps/web/src/app/core/services/wallet.service.ts new file mode 100644 index 0000000..e1313e2 --- /dev/null +++ b/apps/web/src/app/core/services/wallet.service.ts @@ -0,0 +1,94 @@ +import { Injectable, signal, WritableSignal } from '@angular/core'; +import { getWallets } from '@wallet-standard/app'; +import type { Wallet, WalletAccount } from '@wallet-standard/base'; + +type ConnectFeature = { + connect: () => Promise<{ accounts: readonly WalletAccount[] }>; +}; + +type DisconnectFeature = { + disconnect: () => Promise; +}; + +@Injectable({ providedIn: 'root' }) +export class SolanaWalletService { + private readonly walletStore = getWallets(); + + wallet: WritableSignal = signal(null); + account: WritableSignal = signal(null); + + constructor() { + // Auto-pick first discovered wallet + const discoveredWallet = this.walletStore.get()[0] ?? null; + this.wallet.set(discoveredWallet); + + this.walletStore.on('register', (nextWallet) => { + if (!this.wallet()) this.wallet.set(nextWallet); + }); + } + + async Connect(): Promise { + const selectedWallet = this.wallet(); + if (!selectedWallet) throw new Error('No wallet found'); + + const connectFeature = selectedWallet.features[ + 'standard:connect' + ] as ConnectFeature | undefined; + if (!connectFeature) throw new Error('Wallet does not support connect'); + + const connected = await connectFeature.connect(); + const connectedAccount = connected.accounts?.[0] ?? null; + this.account.set(connectedAccount); + } + + async Disconnect(): Promise { + const selectedWallet = this.wallet(); + const disconnectFeature = selectedWallet?.features[ + 'standard:disconnect' + ] as DisconnectFeature | undefined; + if (disconnectFeature) await disconnectFeature.disconnect(); + this.account.set(null); + } + + GetAddress(): string { + const connectedAccount = this.account(); + if (!connectedAccount) return ''; + return connectedAccount.address; + } + + async SignAndSendUnsignedTransaction(unsignedTxBase64: string): Promise { + const selectedWallet = this.wallet(); + const connectedAccount = this.account(); + if (!selectedWallet || !connectedAccount) { + throw new Error('Connect wallet first'); + } + const feature = selectedWallet.features['solana:signAndSendTransaction'] as + | { + signAndSendTransaction: (input: { + account: WalletAccount; + transaction: Uint8Array; + chain?: string; + }) => Promise; + } + | undefined; + if (!feature) { + throw new Error('Wallet does not support solana:signAndSendTransaction'); + } + const transactionBytes = this.Base64ToBytes(unsignedTxBase64); + const result = await feature.signAndSendTransaction({ + account: connectedAccount, + transaction: transactionBytes, + chain: 'solana:devnet', // match backend network + }); + return result[0].signature; + } + + private Base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } +} \ No newline at end of file diff --git a/apps/web/src/app/data/services/index.ts b/apps/web/src/app/data/services/index.ts index 56e9838..86f1883 100644 --- a/apps/web/src/app/data/services/index.ts +++ b/apps/web/src/app/data/services/index.ts @@ -9,3 +9,4 @@ export * from './setup.service'; export * from './topup.service'; export * from './transaction.service'; export * from './webhook-endpoint.service'; +export * from './subscriptions.service'; diff --git a/apps/web/src/app/data/services/subscriptions.service.ts b/apps/web/src/app/data/services/subscriptions.service.ts new file mode 100644 index 0000000..803479a --- /dev/null +++ b/apps/web/src/app/data/services/subscriptions.service.ts @@ -0,0 +1,38 @@ +import { Injectable, inject, signal, WritableSignal } from '@angular/core'; +import { ApiService } from '../../core'; + +@Injectable({ + providedIn: 'root', +}) +export class SubscriptionsService { + private readonly api = inject(ApiService); + + async CreateSubscription(subscriberPublicKey: string, amount: number, periodSeconds: number): Promise { + return await this.api.Call('POST', 'subscriptions', { + subscriberPublicKey: subscriberPublicKey, + amount: amount, + periodSeconds: periodSeconds, + }); + } + + async GetSubscription(subscriberPublicKey: string): Promise { + return await this.api.Call('GET', `subscriptions/${subscriberPublicKey}`); + } + + async CancelSubscription(subscriberPublicKey: string): Promise { + return await this.api.Call('POST', 'subscriptions/cancel', { + subscriberPublicKey: subscriberPublicKey, + }); + } + + async ChargeSubscription(subscriberPublicKey: string, feePayerPublicKey: string): Promise { + return await this.api.Call('POST', 'subscriptions/charge', { + subscriberPublicKey: subscriberPublicKey, + feePayerPublicKey: feePayerPublicKey, + }); + } + + async GetSubscriptionDebugInfo(subscriberPublicKey: string): Promise { + return await this.api.Call('GET', `subscriptions/${subscriberPublicKey}/debug`); + } +} diff --git a/apps/web/src/app/features/checkout/checkout.component.html b/apps/web/src/app/features/checkout/checkout.component.html new file mode 100644 index 0000000..37ca152 --- /dev/null +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -0,0 +1,15 @@ +
+
Connect Wallet
+
Get Address
+
+ + + + +
+
Subscribe
+
Get Subscription
+
Cancel Subscription
+
Charge Subscription
+
Get Subscription Debug Info
+
diff --git a/apps/web/src/app/features/checkout/checkout.component.scss b/apps/web/src/app/features/checkout/checkout.component.scss new file mode 100644 index 0000000..fcb93af --- /dev/null +++ b/apps/web/src/app/features/checkout/checkout.component.scss @@ -0,0 +1,18 @@ +@use '../../styles/base.scss' as *; +@use '../../styles/buttons.scss' as *; + +.checkout-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +} + +.input-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; +} \ No newline at end of file diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts new file mode 100644 index 0000000..caeeaca --- /dev/null +++ b/apps/web/src/app/features/checkout/checkout.component.ts @@ -0,0 +1,91 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { SolanaWalletService } from '../../core'; +import { SubscriptionsService } from '../../data/services'; +import bs58 from 'bs58'; +import { FormsModule } from '@angular/forms'; + +import { + Connection, +} from '@solana/web3.js'; + + +@Component({ + selector: 'app-checkout', + imports: [FormsModule], + templateUrl: './checkout.component.html', + styleUrl: './checkout.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CheckoutComponent { + private readonly solanaWalletService = inject(SolanaWalletService); + private readonly subscriptionsService = inject(SubscriptionsService); + + amountInCents : number = 1; + periodSeconds : number = 30; + + async ConnectWallet() { + await this.solanaWalletService.Connect(); + } + + GetAddress() { + console.log(this.solanaWalletService.GetAddress()); + } + + async Subscribe() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const prepared = await this.subscriptionsService.CreateSubscription(subscriberPublicKey, this.amountInCents, this.periodSeconds); + const signature = await this.solanaWalletService.SignAndSendUnsignedTransaction( + prepared.unsigned_transaction + ); + console.log('signature bytes:', signature); + } + + async GetSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.GetSubscription(subscriberPublicKey); + console.log(result); + } + + async CancelSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.CancelSubscription(subscriberPublicKey); + console.log(result); + const signature = await this.solanaWalletService.SignAndSendUnsignedTransaction( + result.unsigned_transaction + ); + console.log('signature bytes:', signature); + } + + + async ChargeSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const prepared = await this.subscriptionsService.ChargeSubscription( + subscriberPublicKey, + subscriberPublicKey + ); + const signatureBytes = await this.solanaWalletService.SignAndSendUnsignedTransaction( + prepared.unsigned_transaction + ); + const signature = bs58.encode(signatureBytes); + console.log('signature:', signature); + const connection = new Connection('https://api.devnet.solana.com', 'confirmed'); + const confirmation = await connection.confirmTransaction( + { + signature, + blockhash: prepared.blockhash, + lastValidBlockHeight: prepared.last_valid_block_height, + }, + 'confirmed' + ); + console.log('confirmation:', confirmation); + const state = await this.subscriptionsService.GetSubscription(subscriberPublicKey); + console.log('updated subscription:', state); + } + + async GetSubscriptionDebugInfo() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.GetSubscriptionDebugInfo(subscriberPublicKey); + console.log(result); + } + +} diff --git a/apps/web/src/app/features/checkout/index.ts b/apps/web/src/app/features/checkout/index.ts new file mode 100644 index 0000000..c10721e --- /dev/null +++ b/apps/web/src/app/features/checkout/index.ts @@ -0,0 +1 @@ +export * from './checkout.component'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1ad0141..c827949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@angular/router": "~20.3.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", + "@wallet-standard/app": "^1.1.0", "axios": "^1.6.0", "bs58": "^6.0.0", "cors": "^2.8.5", @@ -10867,6 +10868,27 @@ "vite": "^6.0.0 || ^7.0.0" } }, + "node_modules/@wallet-standard/app": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/app/-/app-1.1.0.tgz", + "integrity": "sha512-3CijvrO9utx598kjr45hTbbeeykQrQfKmSnxeWOgU25TOEpvcipD/bYDQWIqUv1Oc6KK4YStokSMu/FBNecGUQ==", + "license": "Apache-2.0", + "dependencies": { + "@wallet-standard/base": "^1.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@wallet-standard/base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wallet-standard/base/-/base-1.1.0.tgz", + "integrity": "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", diff --git a/package.json b/package.json index 0f56463..f189eba 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@angular/router": "~20.3.0", "@solana/spl-token": "^0.4.14", "@solana/web3.js": "^1.98.4", + "@wallet-standard/app": "^1.1.0", "axios": "^1.6.0", "bs58": "^6.0.0", "cors": "^2.8.5", From 405f9e19f4fd4aa3c08315af6e0d3a21737c0555 Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Mon, 11 May 2026 13:57:08 +0100 Subject: [PATCH 2/4] chore: fix lints + formatting errors --- apps/api/src/modules/Subscription.ts | 20 ++- apps/api/src/modules/chains/Solana.ts | 68 ++++++--- apps/api/src/routes/subscriptions.routes.ts | 18 ++- apps/web/src/app/app.routes.ts | 2 +- .../src/app/core/services/wallet.service.ts | 78 +++++----- .../data/services/subscriptions.service.ts | 51 ++++--- .../features/checkout/checkout.component.html | 44 ++++-- .../features/checkout/checkout.component.scss | 2 +- .../features/checkout/checkout.component.ts | 139 ++++++++++-------- apps/web/src/app/features/checkout/index.ts | 2 +- 10 files changed, 257 insertions(+), 167 deletions(-) diff --git a/apps/api/src/modules/Subscription.ts b/apps/api/src/modules/Subscription.ts index a890ff6..473d71d 100644 --- a/apps/api/src/modules/Subscription.ts +++ b/apps/api/src/modules/Subscription.ts @@ -8,14 +8,20 @@ import { Solana } from './chains/Solana'; export class SubscriptionModule { - constructor() { + constructor() {} - } - - async CreateSubscription(subscriberPublicKey: string, amount: number, periodSeconds: number): Promise { - const solana = new Solana(); - const result = await solana.CreateSubscription(subscriberPublicKey, amount, periodSeconds); - return result; + async CreateSubscription( + subscriberPublicKey: string, + amount: number, + periodSeconds: number + ): Promise { + const solana = new Solana(); + const result = await solana.CreateSubscription( + subscriberPublicKey, + amount, + periodSeconds + ); + return result; } async GetSubscription(subscriberPublicKey: string): Promise { diff --git a/apps/api/src/modules/chains/Solana.ts b/apps/api/src/modules/chains/Solana.ts index 4192779..17f8da1 100644 --- a/apps/api/src/modules/chains/Solana.ts +++ b/apps/api/src/modules/chains/Solana.ts @@ -585,7 +585,7 @@ export class Solana { async CreateSubscription( subscriberPublicKey: string, amount: number, - periodSeconds: number, + periodSeconds: number ): Promise<{ unsigned_transaction: string; estimated_fee_lamports: number; @@ -611,10 +611,10 @@ export class Solana { // Input amount is expected in USD (e.g. 0.01), then converted: // dollars -> cents -> USDC atomic units (6 decimals => 1 cent = 10_000 units) - const chargeAmountAtomic = amount * 10_000; - - // Approve for 60 months (60 times monthly price) - const approvedAmountAtomic = chargeAmountAtomic * 60; + const chargeAmountAtomic = amount * 10_000; + + // Approve for 60 months (60 times monthly price) + const approvedAmountAtomic = chargeAmountAtomic * 60; // Combined one-signature flow: // 1) Approve delegate (authority PDA) to spend subscription amount @@ -640,7 +640,10 @@ export class Solana { transaction.feePayer = subscriberPubkey; const fee = await this.WithRetry(() => - this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + this.connection.getFeeForMessage( + transaction.compileMessage(), + 'confirmed' + ) ); const serializedTransaction = transaction @@ -664,7 +667,7 @@ export class Solana { } private GetMerchantPubkey(): PublicKey { - const MERCHANT_PUBLIC_KEY = "HxXyrNpFT6oioH2whDC3oCu54auNcQUhSwh6yX48EsEJ"; + const MERCHANT_PUBLIC_KEY = 'HxXyrNpFT6oioH2whDC3oCu54auNcQUhSwh6yX48EsEJ'; return new PublicKey(MERCHANT_PUBLIC_KEY); } @@ -735,7 +738,11 @@ export class Solana { programId: this.GetProgramId(), keys: [ { pubkey: subscriberPubkey, isSigner: true, isWritable: true }, - { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { + pubkey: this.GetMerchantPubkey(), + isSigner: false, + isWritable: false, + }, { pubkey: mintPubkey, isSigner: false, isWritable: false }, { pubkey: this.GetSubscriptionPda(subscriberPubkey), @@ -765,7 +772,9 @@ export class Solana { throw new Error('Subscriber public key is required'); } - const programId = new PublicKey('9JAVCcRBhZz4Jjx1qMdCgZj7bzEkVE5aMSeHwM94278F'); + const programId = new PublicKey( + '9JAVCcRBhZz4Jjx1qMdCgZj7bzEkVE5aMSeHwM94278F' + ); const merchantPubkey = new PublicKey( 'HxXyrNpFT6oioH2whDC3oCu54auNcQUhSwh6yX48EsEJ' ); @@ -792,7 +801,9 @@ export class Solana { } if (!accountInfo.owner.equals(programId)) { - throw new Error('Subscription PDA exists but is not owned by the program'); + throw new Error( + 'Subscription PDA exists but is not owned by the program' + ); } const data = accountInfo.data; @@ -803,9 +814,13 @@ export class Solana { let offset = 8; // Skip account discriminator - const subscriber = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); + const subscriber = new PublicKey( + data.subarray(offset, offset + 32) + ).toBase58(); offset += 32; - const merchant = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); + const merchant = new PublicKey( + data.subarray(offset, offset + 32) + ).toBase58(); offset += 32; const mint = new PublicKey(data.subarray(offset, offset + 32)).toBase58(); offset += 32; @@ -854,7 +869,8 @@ export class Solana { } const subscriberPubkey = new PublicKey(subscriberPublicKey); - const cancelInstruction = this.CancelSubscriptionInstruction(subscriberPubkey); + const cancelInstruction = + this.CancelSubscriptionInstruction(subscriberPubkey); const transaction = new Transaction().add(cancelInstruction); const { blockhash, lastValidBlockHeight } = await this.WithRetry(() => @@ -864,7 +880,10 @@ export class Solana { transaction.feePayer = subscriberPubkey; const fee = await this.WithRetry(() => - this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + this.connection.getFeeForMessage( + transaction.compileMessage(), + 'confirmed' + ) ); const serializedTransaction = transaction @@ -937,7 +956,10 @@ export class Solana { transaction.feePayer = feePayerPubkey; const fee = await this.WithRetry(() => - this.connection.getFeeForMessage(transaction.compileMessage(), 'confirmed') + this.connection.getFeeForMessage( + transaction.compileMessage(), + 'confirmed' + ) ); const serializedTransaction = transaction @@ -1000,7 +1022,9 @@ export class Solana { ) ); - const parseTokenInfo = (accountInfo: Awaited>) => { + const parseTokenInfo = ( + accountInfo: Awaited> + ) => { if (!accountInfo) { return { token_account_exists: false, @@ -1073,7 +1097,11 @@ export class Solana { isWritable: true, }, { pubkey: subscriberPubkey, isSigner: false, isWritable: true }, - { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { + pubkey: this.GetMerchantPubkey(), + isSigner: false, + isWritable: false, + }, { pubkey: subscriberPubkey, isSigner: true, isWritable: false }, ], data: discriminator, @@ -1099,7 +1127,11 @@ export class Solana { isWritable: true, }, { pubkey: subscriberPubkey, isSigner: false, isWritable: false }, - { pubkey: this.GetMerchantPubkey(), isSigner: false, isWritable: false }, + { + pubkey: this.GetMerchantPubkey(), + isSigner: false, + isWritable: false, + }, { pubkey: new PublicKey(this.GetUSDCMintAddress()), isSigner: false, diff --git a/apps/api/src/routes/subscriptions.routes.ts b/apps/api/src/routes/subscriptions.routes.ts index 44cdd7b..859590e 100644 --- a/apps/api/src/routes/subscriptions.routes.ts +++ b/apps/api/src/routes/subscriptions.routes.ts @@ -11,7 +11,9 @@ router.get( const subscriberPublicKey = req.params.subscriberPublicKey; const subscriptionModule = new SubscriptionModule(); - const result = await subscriptionModule.GetSubscription(subscriberPublicKey); + const result = await subscriptionModule.GetSubscription( + subscriberPublicKey + ); Logger.info('Fetched subscription state', { subscriberPublicKey, @@ -27,13 +29,17 @@ router.post( '/', AsyncHandler(async (req: express.Request, res: express.Response) => { const accountId = req.user.account; - const subscriberPublicKey = req.body.subscriberPublicKey; - const amount = req.body.amount; - const periodSeconds = req.body.periodSeconds; + const subscriberPublicKey = req.body.subscriberPublicKey; + const amount = req.body.amount; + const periodSeconds = req.body.periodSeconds; Logger.info('Subscription account', { accountId }); - const subscriptionModule = new SubscriptionModule(); - const result = await subscriptionModule.CreateSubscription(subscriberPublicKey, amount, periodSeconds); + const subscriptionModule = new SubscriptionModule(); + const result = await subscriptionModule.CreateSubscription( + subscriberPublicKey, + amount, + periodSeconds + ); Logger.info('Subscription called'); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 847f892..ea01bcf 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -48,7 +48,7 @@ export const routes: Routes = [ (mod) => mod.SessionExpiredComponent ), }, - { + { path: 'checkout', loadComponent: () => import('./features/checkout/checkout.component').then( diff --git a/apps/web/src/app/core/services/wallet.service.ts b/apps/web/src/app/core/services/wallet.service.ts index e1313e2..ee7b9d6 100644 --- a/apps/web/src/app/core/services/wallet.service.ts +++ b/apps/web/src/app/core/services/wallet.service.ts @@ -31,9 +31,9 @@ export class SolanaWalletService { const selectedWallet = this.wallet(); if (!selectedWallet) throw new Error('No wallet found'); - const connectFeature = selectedWallet.features[ - 'standard:connect' - ] as ConnectFeature | undefined; + const connectFeature = selectedWallet.features['standard:connect'] as + | ConnectFeature + | undefined; if (!connectFeature) throw new Error('Wallet does not support connect'); const connected = await connectFeature.connect(); @@ -56,39 +56,41 @@ export class SolanaWalletService { return connectedAccount.address; } - async SignAndSendUnsignedTransaction(unsignedTxBase64: string): Promise { - const selectedWallet = this.wallet(); - const connectedAccount = this.account(); - if (!selectedWallet || !connectedAccount) { - throw new Error('Connect wallet first'); - } - const feature = selectedWallet.features['solana:signAndSendTransaction'] as - | { - signAndSendTransaction: (input: { - account: WalletAccount; - transaction: Uint8Array; - chain?: string; - }) => Promise; - } - | undefined; - if (!feature) { - throw new Error('Wallet does not support solana:signAndSendTransaction'); - } - const transactionBytes = this.Base64ToBytes(unsignedTxBase64); - const result = await feature.signAndSendTransaction({ - account: connectedAccount, - transaction: transactionBytes, - chain: 'solana:devnet', // match backend network - }); - return result[0].signature; - } + async SignAndSendUnsignedTransaction( + unsignedTxBase64: string + ): Promise { + const selectedWallet = this.wallet(); + const connectedAccount = this.account(); + if (!selectedWallet || !connectedAccount) { + throw new Error('Connect wallet first'); + } + const feature = selectedWallet.features['solana:signAndSendTransaction'] as + | { + signAndSendTransaction: (input: { + account: WalletAccount; + transaction: Uint8Array; + chain?: string; + }) => Promise; + } + | undefined; + if (!feature) { + throw new Error('Wallet does not support solana:signAndSendTransaction'); + } + const transactionBytes = this.Base64ToBytes(unsignedTxBase64); + const result = await feature.signAndSendTransaction({ + account: connectedAccount, + transaction: transactionBytes, + chain: 'solana:devnet', // match backend network + }); + return result[0].signature; + } - private Base64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i += 1) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; - } -} \ No newline at end of file + private Base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } +} diff --git a/apps/web/src/app/data/services/subscriptions.service.ts b/apps/web/src/app/data/services/subscriptions.service.ts index 803479a..6cd3e6c 100644 --- a/apps/web/src/app/data/services/subscriptions.service.ts +++ b/apps/web/src/app/data/services/subscriptions.service.ts @@ -7,32 +7,45 @@ import { ApiService } from '../../core'; export class SubscriptionsService { private readonly api = inject(ApiService); - async CreateSubscription(subscriberPublicKey: string, amount: number, periodSeconds: number): Promise { + async CreateSubscription( + subscriberPublicKey: string, + amount: number, + periodSeconds: number + ): Promise { return await this.api.Call('POST', 'subscriptions', { subscriberPublicKey: subscriberPublicKey, amount: amount, - periodSeconds: periodSeconds, + periodSeconds: periodSeconds, }); } - async GetSubscription(subscriberPublicKey: string): Promise { - return await this.api.Call('GET', `subscriptions/${subscriberPublicKey}`); - } + async GetSubscription(subscriberPublicKey: string): Promise { + return await this.api.Call( + 'GET', + `subscriptions/${subscriberPublicKey}` + ); + } - async CancelSubscription(subscriberPublicKey: string): Promise { - return await this.api.Call('POST', 'subscriptions/cancel', { - subscriberPublicKey: subscriberPublicKey, - }); - } + async CancelSubscription(subscriberPublicKey: string): Promise { + return await this.api.Call('POST', 'subscriptions/cancel', { + subscriberPublicKey: subscriberPublicKey, + }); + } - async ChargeSubscription(subscriberPublicKey: string, feePayerPublicKey: string): Promise { - return await this.api.Call('POST', 'subscriptions/charge', { - subscriberPublicKey: subscriberPublicKey, - feePayerPublicKey: feePayerPublicKey, - }); - } + async ChargeSubscription( + subscriberPublicKey: string, + feePayerPublicKey: string + ): Promise { + return await this.api.Call('POST', 'subscriptions/charge', { + subscriberPublicKey: subscriberPublicKey, + feePayerPublicKey: feePayerPublicKey, + }); + } - async GetSubscriptionDebugInfo(subscriberPublicKey: string): Promise { - return await this.api.Call('GET', `subscriptions/${subscriberPublicKey}/debug`); - } + async GetSubscriptionDebugInfo(subscriberPublicKey: string): Promise { + return await this.api.Call( + 'GET', + `subscriptions/${subscriberPublicKey}/debug` + ); + } } diff --git a/apps/web/src/app/features/checkout/checkout.component.html b/apps/web/src/app/features/checkout/checkout.component.html index 37ca152..bbe0dcf 100644 --- a/apps/web/src/app/features/checkout/checkout.component.html +++ b/apps/web/src/app/features/checkout/checkout.component.html @@ -1,15 +1,33 @@
-
Connect Wallet
-
Get Address
-
- - - - -
-
Subscribe
-
Get Subscription
-
Cancel Subscription
-
Charge Subscription
-
Get Subscription Debug Info
+ + +
+ + + + +
+ + + + +
diff --git a/apps/web/src/app/features/checkout/checkout.component.scss b/apps/web/src/app/features/checkout/checkout.component.scss index fcb93af..317d717 100644 --- a/apps/web/src/app/features/checkout/checkout.component.scss +++ b/apps/web/src/app/features/checkout/checkout.component.scss @@ -15,4 +15,4 @@ align-items: center; justify-content: center; gap: 10px; -} \ No newline at end of file +} diff --git a/apps/web/src/app/features/checkout/checkout.component.ts b/apps/web/src/app/features/checkout/checkout.component.ts index caeeaca..eb0694b 100644 --- a/apps/web/src/app/features/checkout/checkout.component.ts +++ b/apps/web/src/app/features/checkout/checkout.component.ts @@ -4,10 +4,7 @@ import { SubscriptionsService } from '../../data/services'; import bs58 from 'bs58'; import { FormsModule } from '@angular/forms'; -import { - Connection, -} from '@solana/web3.js'; - +import { Connection } from '@solana/web3.js'; @Component({ selector: 'app-checkout', @@ -18,74 +15,90 @@ import { }) export class CheckoutComponent { private readonly solanaWalletService = inject(SolanaWalletService); - private readonly subscriptionsService = inject(SubscriptionsService); + private readonly subscriptionsService = inject(SubscriptionsService); - amountInCents : number = 1; - periodSeconds : number = 30; + amountInCents = 1; + periodSeconds = 30; async ConnectWallet() { await this.solanaWalletService.Connect(); } - GetAddress() { - console.log(this.solanaWalletService.GetAddress()); - } - - async Subscribe() { - const subscriberPublicKey = this.solanaWalletService.GetAddress(); - const prepared = await this.subscriptionsService.CreateSubscription(subscriberPublicKey, this.amountInCents, this.periodSeconds); - const signature = await this.solanaWalletService.SignAndSendUnsignedTransaction( - prepared.unsigned_transaction - ); - console.log('signature bytes:', signature); - } + GetAddress() { + console.log(this.solanaWalletService.GetAddress()); + } - async GetSubscription() { - const subscriberPublicKey = this.solanaWalletService.GetAddress(); - const result = await this.subscriptionsService.GetSubscription(subscriberPublicKey); - console.log(result); - } + async Subscribe() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const prepared = await this.subscriptionsService.CreateSubscription( + subscriberPublicKey, + this.amountInCents, + this.periodSeconds + ); + const signature = + await this.solanaWalletService.SignAndSendUnsignedTransaction( + prepared.unsigned_transaction + ); + console.log('signature bytes:', signature); + } - async CancelSubscription() { - const subscriberPublicKey = this.solanaWalletService.GetAddress(); - const result = await this.subscriptionsService.CancelSubscription(subscriberPublicKey); - console.log(result); - const signature = await this.solanaWalletService.SignAndSendUnsignedTransaction( - result.unsigned_transaction - ); - console.log('signature bytes:', signature); - } + async GetSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.GetSubscription( + subscriberPublicKey + ); + console.log(result); + } + async CancelSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.CancelSubscription( + subscriberPublicKey + ); + console.log(result); + const signature = + await this.solanaWalletService.SignAndSendUnsignedTransaction( + result.unsigned_transaction + ); + console.log('signature bytes:', signature); + } - async ChargeSubscription() { - const subscriberPublicKey = this.solanaWalletService.GetAddress(); - const prepared = await this.subscriptionsService.ChargeSubscription( - subscriberPublicKey, - subscriberPublicKey - ); - const signatureBytes = await this.solanaWalletService.SignAndSendUnsignedTransaction( - prepared.unsigned_transaction - ); - const signature = bs58.encode(signatureBytes); - console.log('signature:', signature); - const connection = new Connection('https://api.devnet.solana.com', 'confirmed'); - const confirmation = await connection.confirmTransaction( - { - signature, - blockhash: prepared.blockhash, - lastValidBlockHeight: prepared.last_valid_block_height, - }, - 'confirmed' - ); - console.log('confirmation:', confirmation); - const state = await this.subscriptionsService.GetSubscription(subscriberPublicKey); - console.log('updated subscription:', state); - } + async ChargeSubscription() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const prepared = await this.subscriptionsService.ChargeSubscription( + subscriberPublicKey, + subscriberPublicKey + ); + const signatureBytes = + await this.solanaWalletService.SignAndSendUnsignedTransaction( + prepared.unsigned_transaction + ); + const signature = bs58.encode(signatureBytes); + console.log('signature:', signature); + const connection = new Connection( + 'https://api.devnet.solana.com', + 'confirmed' + ); + const confirmation = await connection.confirmTransaction( + { + signature, + blockhash: prepared.blockhash, + lastValidBlockHeight: prepared.last_valid_block_height, + }, + 'confirmed' + ); + console.log('confirmation:', confirmation); + const state = await this.subscriptionsService.GetSubscription( + subscriberPublicKey + ); + console.log('updated subscription:', state); + } - async GetSubscriptionDebugInfo() { - const subscriberPublicKey = this.solanaWalletService.GetAddress(); - const result = await this.subscriptionsService.GetSubscriptionDebugInfo(subscriberPublicKey); - console.log(result); - } - + async GetSubscriptionDebugInfo() { + const subscriberPublicKey = this.solanaWalletService.GetAddress(); + const result = await this.subscriptionsService.GetSubscriptionDebugInfo( + subscriberPublicKey + ); + console.log(result); + } } diff --git a/apps/web/src/app/features/checkout/index.ts b/apps/web/src/app/features/checkout/index.ts index c10721e..0828101 100644 --- a/apps/web/src/app/features/checkout/index.ts +++ b/apps/web/src/app/features/checkout/index.ts @@ -1 +1 @@ -export * from './checkout.component'; \ No newline at end of file +export * from './checkout.component'; From e15b6ddac1caf1f864474f9f9b91477aabde89f5 Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Tue, 12 May 2026 15:05:56 +0100 Subject: [PATCH 3/4] feat: product api endpoints --- apps/api/src/__tests__/Product.spec.ts | 243 +++++++++++++++++++++++++ apps/api/src/modules/Product.ts | 220 ++++++++++++++++++++++ apps/api/src/routes/index.ts | 2 + apps/api/src/routes/products.routes.ts | 229 +++++++++++++++++++++++ apps/api/src/schemas/ProductSchema.ts | 87 +++++++++ apps/api/src/utils/Errors.ts | 5 + apps/api/src/utils/ListHelper.ts | 62 +++++++ libs/shared-types/src/lib/Event.ts | 7 + libs/shared-types/src/lib/Product.ts | 78 ++++++++ libs/shared-types/src/lib/index.ts | 1 + 10 files changed, 934 insertions(+) create mode 100644 apps/api/src/__tests__/Product.spec.ts create mode 100644 apps/api/src/modules/Product.ts create mode 100644 apps/api/src/routes/products.routes.ts create mode 100644 apps/api/src/schemas/ProductSchema.ts create mode 100644 libs/shared-types/src/lib/Product.ts diff --git a/apps/api/src/__tests__/Product.spec.ts b/apps/api/src/__tests__/Product.spec.ts new file mode 100644 index 0000000..b14f5c4 --- /dev/null +++ b/apps/api/src/__tests__/Product.spec.ts @@ -0,0 +1,243 @@ +import { ProductModule } from '../modules/Product'; +import { Database } from '../modules/Database'; +import { Product, QueryOperators } from '@zoneless/shared-types'; +import { ListHelper } from '../utils/ListHelper'; +import { + CreateMockDatabase, + DeterministicId, + ResetIdCounter, + GetFixedTimestamp, +} from './Setup'; + +jest.mock('../modules/Database'); +jest.mock('../utils/IdGenerator', () => ({ + GenerateId: jest.fn((prefix: string) => DeterministicId(prefix)), +})); +jest.mock('../utils/Timestamp', () => ({ + Now: jest.fn(() => GetFixedTimestamp()), +})); +jest.mock('../modules/AppConfig', () => ({ + GetAppConfig: jest.fn(() => ({ + dashboardUrl: 'http://localhost:4200', + livemode: false, + appSecret: 'test-secret', + })), +})); + +describe('ProductModule', () => { + let module: ProductModule; + let mockDb: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + ResetIdCounter(); + mockDb = CreateMockDatabase(); + module = new ProductModule(mockDb); + }); + + describe('ProductObject', () => { + it('should create a product with sensible defaults', () => { + const product = module.ProductObject('acct_z_platform', { + name: 'Test Product', + }); + + expect(product.object).toBe('product'); + expect(product.name).toBe('Test Product'); + expect(product.platform_account).toBe('acct_z_platform'); + expect(product.description).toBeNull(); + expect(product.images).toEqual([]); + expect(product.marketing_features).toEqual([]); + expect(product.livemode).toBe(false); + expect(product.metadata).toEqual({}); + expect(product.tax_code).toBeNull(); + expect(product.default_price).toBeNull(); + expect(product.unit_label).toBeNull(); + expect(product.url).toBeNull(); + }); + + it('should accept provided input fields', () => { + const product = module.ProductObject('acct_z_platform', { + name: 'Test Product', + description: 'Test Description', + images: ['https://example.com/image.jpg'], + marketing_features: [{ name: 'Feature 1' }], + package_dimensions: { height: 10, length: 10, weight: 10, width: 10 }, + shippable: true, + statement_descriptor: 'Test Statement Descriptor', + tax_code: 'Test Tax Code', + unit_label: 'Test Unit Label', + url: 'https://example.com/product', + }); + + expect(product.name).toBe('Test Product'); + expect(product.description).toBe('Test Description'); + expect(product.images).toEqual(['https://example.com/image.jpg']); + expect(product.marketing_features).toEqual([{ name: 'Feature 1' }]); + expect(product.package_dimensions).toEqual({ + height: 10, + length: 10, + weight: 10, + width: 10, + }); + expect(product.shippable).toBe(true); + expect(product.statement_descriptor).toBe('Test Statement Descriptor'); + expect(product.tax_code).toBe('Test Tax Code'); + expect(product.unit_label).toBe('Test Unit Label'); + expect(product.url).toBe('https://example.com/product'); + }); + }); + + describe('CreateProduct', () => { + it('should persist the product to the database', async () => { + const product = await module.CreateProduct('acct_z_platform', { + name: 'Test Product', + }); + expect(mockDb.Set).toHaveBeenCalledTimes(1); + expect(mockDb.Set).toHaveBeenCalledWith('Products', product.id, product); + }); + }); + + describe('UpdateProduct', () => { + it('should update product metadata', async () => { + const existingProduct = { + id: 'prod_z_1', + object: 'product', + platform_account: 'acct_z_1', + metadata: {}, + } as Product; + mockDb.Get = jest.fn().mockResolvedValue(existingProduct); + + const result = await module.UpdateProduct('prod_z_1', { + metadata: { tracking_number: '12345' }, + }); + + expect(mockDb.Update).toHaveBeenCalledWith( + 'Products', + 'prod_z_1', + expect.objectContaining({ metadata: { tracking_number: '12345' } }) + ); + expect(result).toEqual(existingProduct); + }); + + it('should throw when product not found', async () => { + await expect( + module.UpdateProduct('nonexistent', { metadata: {} }) + ).rejects.toThrow('Product not found'); + }); + }); + + describe('GetProduct', () => { + it('should return the product when found', async () => { + const mockProduct = { id: 'prod_z_1', object: 'product' } as Product; + mockDb.Get = jest.fn().mockResolvedValue(mockProduct); + + const result = await module.GetProduct('prod_z_1'); + expect(result).toEqual(mockProduct); + }); + + it('should return null when not found', async () => { + const result = await module.GetProduct('nonexistent'); + expect(result).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // DeleteProduct + // ----------------------------------------------------------------------- + describe('DeleteProduct', () => { + it('should delete the product and return confirmation', async () => { + mockDb.Get = jest + .fn() + .mockResolvedValue({ id: 'prod_z_1', object: 'product' }); + + const result = await module.DeleteProduct('prod_z_1'); + + expect(mockDb.Delete).toHaveBeenCalledWith('Products', 'prod_z_1'); + expect(result).toEqual({ + id: 'prod_z_1', + object: 'product', + deleted: true, + }); + }); + + it('should throw when product not found', async () => { + await expect(module.DeleteProduct('nonexistent')).rejects.toThrow( + 'Product not found' + ); + }); + }); + + // ----------------------------------------------------------------------- + // ListProducts + // ----------------------------------------------------------------------- + describe('ListProducts', () => { + it('should pass account and product filters to ListHelper', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ + account: 'acct_z_platform', + limit: 25, + active: true, + shippable: false, + ids: ['prod_z_1', 'prod_z_2'], + url: 'https://example.com/p', + }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + account: 'acct_z_platform', + limit: 25, + filters: expect.objectContaining({ + active: true, + shippable: false, + url: 'https://example.com/p', + id: { + operator: QueryOperators['in'], + value: ['prod_z_1', 'prod_z_2'], + }, + }), + }) + ); + listSpy.mockRestore(); + }); + it('should omit optional filters when not provided', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ account: 'acct_z_platform' }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + filters: {}, + }) + ); + listSpy.mockRestore(); + }); + it('should not add ids filter when ids is empty', async () => { + const listSpy = jest + .spyOn(ListHelper.prototype, 'List') + .mockResolvedValue({ + object: 'list', + data: [], + has_more: false, + url: '/v1/products', + }); + await module.ListProducts({ account: 'acct_z_platform', ids: [] }); + expect(listSpy).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.not.objectContaining({ id: expect.anything() }), + }) + ); + listSpy.mockRestore(); + }); + }); +}); diff --git a/apps/api/src/modules/Product.ts b/apps/api/src/modules/Product.ts new file mode 100644 index 0000000..8938c1c --- /dev/null +++ b/apps/api/src/modules/Product.ts @@ -0,0 +1,220 @@ +/** + * @fileOverview Methods for Products + * + * + * @module Product + */ + +import { Database } from './Database'; +import { EventService } from './EventService'; +import { GenerateId } from '../utils/IdGenerator'; +import { ExtractChangedFields } from './Event'; +import { + Product as ProductType, + ProductDeleted, + QueryOperators, +} from '@zoneless/shared-types'; +import { ValidateUpdate } from './Util'; +import { + CreateProductSchema, + CreateProductInput, + UpdateProductSchema, + UpdateProductInput, + ListProductsFiltersInput, +} from '../schemas/ProductSchema'; +import { ListHelper, ListOptions, ListResult } from '../utils/ListHelper'; +import { Now } from '../utils/Timestamp'; +import { GetAppConfig } from './AppConfig'; +import { AppError } from '../utils/AppError'; +import { ERRORS } from '../utils/Errors'; + +export class ProductModule { + private readonly db: Database; + private readonly eventService: EventService | null; + private readonly listHelper: ListHelper; + + constructor(db: Database, eventService?: EventService) { + this.db = db; + this.eventService = eventService || null; + this.listHelper = new ListHelper(db, { + collection: 'Products', + orderByField: 'created', + orderDirection: 'desc', + urlPath: '/v1/products', + accountField: 'platform_account', + }); + } + + /** + * Create a new product. + * + * @param platformAccountId - The platform account ID + * @param input - The input data for the product + * @returns The created product + */ + async CreateProduct( + platformAccountId: string, + input: CreateProductInput + ): Promise { + const validatedInput = ValidateUpdate(CreateProductSchema, input); + + const product = this.ProductObject(platformAccountId, validatedInput); + await this.db.Set('Products', product.id, product); + + if (this.eventService) { + await this.eventService.Emit( + 'product.created', + product.platform_account, + product + ); + } + return product; + } + + ProductObject( + platformAccountId: string, + input: CreateProductInput + ): ProductType { + const product: ProductType = { + id: GenerateId('prod_z'), + object: 'product', + active: input.active ?? true, + created: Now(), + default_price: input.default_price ?? null, + description: input.description ?? null, + images: input.images ?? [], + marketing_features: input.marketing_features ?? [], + livemode: GetAppConfig().livemode, + metadata: input.metadata ?? {}, + name: input.name, + package_dimensions: input.package_dimensions ?? null, + shippable: input.shippable ?? null, + statement_descriptor: input.statement_descriptor ?? null, + tax_code: input.tax_code ?? null, + unit_label: input.unit_label ?? null, + updated: Now(), + url: input.url ?? null, + platform_account: platformAccountId, + }; + return product; + } + + /** + * Get a product by its ID. + * + * @param id - The product ID + * @returns The Product if found, null otherwise + */ + async GetProduct(id: string): Promise { + return this.db.Get('Products', id); + } + + /** + * Update a product. + * Emits an 'product.updated' event if EventService is configured. + * + * @param id - The Product ID + * @param input - The fields to update + * @returns The updated Product + */ + async UpdateProduct( + id: string, + input: UpdateProductInput + ): Promise { + const validatedUpdate = ValidateUpdate(UpdateProductSchema, input); + + // Get previous state for the event (before update) + const previousProduct = this.eventService + ? await this.GetProduct(id) + : null; + + await this.db.Update('Products', id, validatedUpdate); + + const product = await this.GetProduct(id); + if (!product) { + throw new Error('Product not found after update'); + } + + // Emit product.updated event + if (this.eventService && previousProduct) { + const previousAttributes = ExtractChangedFields( + previousProduct as unknown as Record, + validatedUpdate as Record + ); + + await this.eventService.Emit( + 'product.updated', + product.platform_account, + product, + { + previousAttributes, + } + ); + } + + return product; + } + + /** + * Delete a product. + * Emits an 'product.deleted' event if EventService is configured. + * + * @param id - The product ID + * @returns Deletion confirmation object + */ + async DeleteProduct(id: string): Promise { + // Get the product before deletion for the event + const product = await this.GetProduct(id); + + if (!product) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + await this.db.Delete('Products', id); + + // Emit product.deleted event + if (this.eventService && product) { + await this.eventService.Emit( + 'product.deleted', + product.platform_account, + product + ); + } + + return { + id, + object: 'product', + deleted: true, + }; + } + + /** + * List products + */ + async ListProducts( + options: ListOptions & ListProductsFiltersInput + ): Promise> { + const { active, shippable, ids, url, ...listOptions } = options; + + // Build filters + const filters: Record = {}; + if (active !== undefined) filters.active = active; + if (shippable !== undefined) filters.shippable = shippable; + if (ids?.length) { + filters.id = { + operator: QueryOperators['in'], + value: ids, + }; + } + if (url) filters.url = url; + + return this.listHelper.List({ + ...listOptions, + filters: { ...listOptions.filters, ...filters }, + }); + } +} diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 1ec52b7..6dad79a 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -19,6 +19,7 @@ import authExchangeRouter from './exchange.routes'; import configRouter from './config.routes'; import setupRouter from './setup.routes'; import subscriptionsRouter from './subscriptions.routes'; +import productsRouter from './products.routes'; const router = express.Router(); @@ -51,4 +52,5 @@ router.use('/webhook_endpoints', webhookEndpointsRouter); router.use('/api_keys', apiKeysRouter); router.use('/events', eventsRouter); router.use('/subscriptions', subscriptionsRouter); +router.use('/products', productsRouter); export default router; diff --git a/apps/api/src/routes/products.routes.ts b/apps/api/src/routes/products.routes.ts new file mode 100644 index 0000000..e5c198b --- /dev/null +++ b/apps/api/src/routes/products.routes.ts @@ -0,0 +1,229 @@ +import * as express from 'express'; +import { AsyncHandler } from '../utils/AsyncHandler'; +import { AppError } from '../utils/AppError'; +import { ERRORS } from '../utils/Errors'; +import { Logger } from '../utils/Logger'; +import { + ParseCreatedFilter, + ParseOptionalQueryBoolean, +} from '../utils/ListHelper'; + +import { db } from '../modules/Database'; +import { EventService } from '../modules/EventService'; +import { ProductModule } from '../modules/Product'; + +import { ValidateRequest } from '../middleware/ValidateRequest'; +import { RequirePlatform } from '../middleware/Authorization'; + +import { + CreateProductSchema, + UpdateProductSchema, +} from '../schemas/ProductSchema'; + +const router = express.Router(); + +const eventService = new EventService(db); +const productModule = new ProductModule(db, eventService); + +/** + * POST /v1/products + * Create a new product. + */ +router.post( + '/', + RequirePlatform(), + ValidateRequest(CreateProductSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Creating product', { + platformAccountId, + fields: Object.keys(req.body), + }); + + const product = await productModule.CreateProduct( + platformAccountId, + req.body + ); + + Logger.info('Product created successfully', { + productId: product.id, + }); + + res.status(201).json(product); + }) +); + +/** + * POST /v1/products/:id + * Update a product. + */ +router.post( + '/:id', + RequirePlatform(), + ValidateRequest(UpdateProductSchema), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + // Verify the API key exists and belongs to this platform + const existingProduct = await productModule.GetProduct(id); + + // Product exists + if (!existingProduct) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + // Product belongs to this platform + if (existingProduct.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + Logger.info('Updating Product', { + productId: id, + fields: Object.keys(req.body), + }); + + const updatedProduct = await productModule.UpdateProduct(id, req.body); + + Logger.info('Product updated successfully', { + productId: updatedProduct.id, + }); + + res.json(updatedProduct); + }) +); + +/** + * GET /v1/products/:id + * Retrieve a product. + */ +router.get( + '/:id', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + const product = await productModule.GetProduct(id); + + if (!product) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + // Verify the API key belongs to this platform + if (product.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + res.json(product); + }) +); + +/** + * DELETE /v1/products/:id + * Delete a product. + */ +router.delete( + '/:id', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + const id = req.params.id; + + // Verify the API key exists and belongs to this platform + const existingProduct = await productModule.GetProduct(id); + + if (!existingProduct) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + if (existingProduct.platform_account !== platformAccountId) { + throw new AppError( + ERRORS.PRODUCT_NOT_FOUND.message, + ERRORS.PRODUCT_NOT_FOUND.status, + ERRORS.PRODUCT_NOT_FOUND.type + ); + } + + Logger.info('Deleting Product', { productId: id }); + + const result = await productModule.DeleteProduct(id); + + Logger.info('Product deleted successfully', { productId: id }); + + res.json(result); + }) +); + +/** + * GET /v1/products + * Returns a list of products + */ +router.get( + '/', + RequirePlatform(), + AsyncHandler(async (req: express.Request, res: express.Response) => { + const platformAccountId = req.user.account; + + Logger.info('Listing products', { platformAccountId }); + + //Parse query parameters + const limit = req.query.limit + ? parseInt(req.query.limit as string, 10) + : 10; + const startingAfter = req.query.starting_after as string | undefined; + const endingBefore = req.query.ending_before as string | undefined; + const created = ParseCreatedFilter(req.query as Record); + + const active = ParseOptionalQueryBoolean(req.query.active); + const shippable = ParseOptionalQueryBoolean(req.query.shippable); + const url = req.query.url as string | undefined; + let ids: string[] | undefined = undefined; + if (req.query.ids) { + ids = (req.query.ids as string).split(','); + } + + const result = await productModule.ListProducts({ + account: platformAccountId, + limit, + startingAfter, + endingBefore, + created, + active, + shippable, + ids, + url, + }); + + Logger.info('Products listed successfully', { + platformAccountId, + count: result.data.length, + hasMore: result.has_more, + }); + + res.json(result); + }) +); + +export default router; diff --git a/apps/api/src/schemas/ProductSchema.ts b/apps/api/src/schemas/ProductSchema.ts new file mode 100644 index 0000000..c1488db --- /dev/null +++ b/apps/api/src/schemas/ProductSchema.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +const PackageDimensionsSchema = z.object({ + height: z.number().min(0).max(100000), + length: z.number().min(0).max(100000), + weight: z.number().min(0).max(100000), + width: z.number().min(0).max(100000), +}); + +const MarketingFeatureSchema = z.object({ + name: z.string().min(1).max(80), +}); + +/** + * Schema for creating a product. Only name is required. + */ +export const CreateProductSchema = z.object({ + name: z.string().min(1).max(200), + active: z.boolean().default(true).optional(), + description: z.string().max(40000).optional(), + id: z.string().min(1).max(32).optional(), + metadata: z.record(z.string(), z.string()).optional(), + tax_code: z.string().optional(), + default_price: z.any().optional(), //TODO: add price schema + images: z.array(z.string()).max(8).optional(), + marketing_features: z.array(MarketingFeatureSchema).max(15).optional(), + package_dimensions: PackageDimensionsSchema.optional(), + shippable: z.boolean().optional(), + statement_descriptor: z.string().max(22).optional(), + unit_label: z.string().optional(), + url: z.string().url().optional(), +}); + +export type CreateProductInput = z.infer; + +/** + * Schema for updating a product. + */ +export const UpdateProductSchema = z.object({ + active: z.boolean().optional(), + default_price: z.string().optional(), //TODO: add price schema + description: z.string().max(40000).optional(), + metadata: z.record(z.string(), z.string()).optional(), + name: z.string().min(1).max(200).optional(), + tax_code: z.string().optional(), + images: z.array(z.string()).max(8).optional(), + marketing_features: z.array(MarketingFeatureSchema).max(15).optional(), + package_dimensions: PackageDimensionsSchema.optional(), + shippable: z.boolean().optional(), + statement_descriptor: z.string().max(22).optional(), + unit_label: z.string().optional(), + url: z.string().url().optional(), +}); + +export type UpdateProductInput = z.infer; + +/** + * Schema for listing products + */ +export const ListProductsSchema = z.object({ + active: z.boolean().optional(), + created: z + .object({ + gt: z.number().int().optional(), + gte: z.number().int().optional(), + lt: z.number().int().optional(), + lte: z.number().int().optional(), + }) + .optional(), + ending_before: z.string().optional(), + ids: z.array(z.string()).optional(), + limit: z.number().int().min(1).max(100).optional(), + shippable: z.boolean().optional(), + starting_after: z.string().optional(), + url: z.string().url().optional(), +}); +export type ListProductsInput = z.infer; + +export const ListProductsFiltersSchema = z.object({ + active: z.boolean().optional(), + shippable: z.boolean().optional(), + ids: z.array(z.string()).optional(), + url: z.string().url().optional(), +}); +export type ListProductsFiltersInput = z.infer< + typeof ListProductsFiltersSchema +>; diff --git a/apps/api/src/utils/Errors.ts b/apps/api/src/utils/Errors.ts index ba2f9f9..06c8c3a 100644 --- a/apps/api/src/utils/Errors.ts +++ b/apps/api/src/utils/Errors.ts @@ -119,6 +119,11 @@ export const ERRORS = { status: 404, type: 'invalid_request_error', }, + PRODUCT_NOT_FOUND: { + message: 'Product not found', + status: 404, + type: 'resource_missing', + }, // Validation errors VALIDATION_ERROR: { diff --git a/apps/api/src/utils/ListHelper.ts b/apps/api/src/utils/ListHelper.ts index 94761d2..0e52e57 100644 --- a/apps/api/src/utils/ListHelper.ts +++ b/apps/api/src/utils/ListHelper.ts @@ -245,6 +245,7 @@ export type TimestampFilter = * Parse a generic timestamp filter from request query parameters. * Supports simple timestamp format: ?field=123 * And object notation: ?field[gt]=123&field[gte]=456 + * And nested objects from Express/qs: field: { gt, gte, lt, lte } * * @param query - The query parameters object * @param fieldName - The name of the timestamp field (e.g., 'created', 'arrival_date') @@ -287,9 +288,44 @@ export function ParseTimestampFilter( filter.lte = parseInt(String(query[`${fieldName}[lte]`]), 10); } + MergeNestedTimestampBounds(query[fieldName], filter); + return Object.keys(filter).length > 0 ? filter : undefined; } +/** + * Reads gt/gte/lt/lte from a nested query object (Express qs shape for field[gt]=…). + */ +function MergeNestedTimestampBounds( + raw: unknown, + filter: { + gt?: number; + gte?: number; + lt?: number; + lte?: number; + } +): void { + const timestampBoundKeys = ['gt', 'gte', 'lt', 'lte'] as const; + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return; + } + const o = raw as Record; + for (const key of timestampBoundKeys) { + const v = o[key]; + if (v === undefined || v === null || v === '') { + continue; + } + const first = Array.isArray(v) && v.length > 0 ? v[0] : v; + if (first === undefined || first === '') { + continue; + } + const n = parseInt(String(first), 10); + if (Number.isFinite(n)) { + filter[key] = n; + } + } +} + /** * Check if query has bracket notation for a field */ @@ -317,3 +353,29 @@ export function ParseCreatedFilter( ): ListOptions['created'] | undefined { return ParseTimestampFilter(query, 'created'); } + +/** + * Parse optional boolean from Express query values (strings or string[]). + * Supports ?key=true and ?key=false. Unknown or empty values yield undefined. + */ +export function ParseOptionalQueryBoolean(value: unknown): boolean | undefined { + if (value === undefined || value === null) { + return undefined; + } + const first = + typeof value === 'string' + ? value + : Array.isArray(value) && typeof value[0] === 'string' + ? value[0] + : undefined; + if (first === undefined || first === '') { + return undefined; + } + if (first === 'true') { + return true; + } + if (first === 'false') { + return false; + } + return undefined; +} diff --git a/libs/shared-types/src/lib/Event.ts b/libs/shared-types/src/lib/Event.ts index c1d5a28..3f68f0e 100644 --- a/libs/shared-types/src/lib/Event.ts +++ b/libs/shared-types/src/lib/Event.ts @@ -7,6 +7,7 @@ import { ExternalWallet } from './ExternalWallet'; import { IdempotencyKey } from './IdempotencyKey'; import { Payout } from './Payout'; import { Person } from './Person'; +import { Product } from './Product'; import { TopUp } from './TopUp'; import { Transfer, TransferReversal } from './Transfer'; @@ -52,6 +53,11 @@ export const EVENT_TYPES = [ 'person.updated', 'person.deleted', + // Product events + 'product.created', + 'product.updated', + 'product.deleted', + // Top-up events 'topup.created', 'topup.canceled', @@ -81,6 +87,7 @@ export type EventDataObject = | IdempotencyKey | Payout | Person + | Product | TopUp | Transfer | TransferReversal diff --git a/libs/shared-types/src/lib/Product.ts b/libs/shared-types/src/lib/Product.ts new file mode 100644 index 0000000..c0b86fc --- /dev/null +++ b/libs/shared-types/src/lib/Product.ts @@ -0,0 +1,78 @@ +/** + * Stripe-compatible Balance Product object for Zoneless. + * Represents a product that can be sold. + * + * @see https://docs.stripe.com/api/products/object + */ +export interface Product { + /** Unique identifier for the object. */ + id: string; + /** String representing the object's type. Objects of the same type share the same value. */ + object: string; + /** Whether the product is currently available for purchase. */ + active: boolean; + /** Time at which the object was created. Measured in seconds since the Unix epoch.*/ + created: number; + /** The ID of the Price object that is the default price for this product.*/ + default_price: string | null; + /** The product’s description, meant to be displayable to the customer. Use this field to optionally store a long form explanation of the product being sold for your own rendering purposes.*/ + description: string | null; + /** A list of up to 8 URLs of images for this product, meant to be displayable to the customer.*/ + images: string[]; + /** A list of up to 15 marketing features for this product. These are displayed in pricing tables.*/ + marketing_features: MarketingFeature[]; + /** If the object exists in live mode, the value is true. If the object exists in test mode, the value is false.*/ + livemode: boolean; + /** Set of key-value pairs that you can attach to an object. This can be useful for storing additional information about the object in a structured format.*/ + metadata: Record; + /** The product’s name, meant to be displayable to the customer.*/ + name: string; + /** The dimensions of this product for shipping purposes.*/ + package_dimensions: PackageDimensions | null; + /** Whether this product is shipped (i.e., physical goods).*/ + shippable: boolean | null; + /** Extra information about a product which will appear on your customer’s statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only used for subscription payments.*/ + statement_descriptor: string | null; + /** A tax code ID.*/ + tax_code: string | null; + /** A label that represents units of this product. When set, this will be included in customers’ receipts, invoices, Checkout, and the customer portal.*/ + unit_label: string | null; + /** Time at which the object was last updated. Measured in seconds since the Unix epoch.*/ + updated: number; + /** A URL of a publicly-accessible webpage for this product.*/ + url: string | null; + /** + * The platform account that owns this resource. + * @zoneless_extension + */ + platform_account: string; +} + +export interface PackageDimensions { + /** */ + /** Height, in inches.*/ + height: number; + /** Length, in inches.*/ + length: number; + /** Weight, in ounces.*/ + weight: number; + /** Width, in inches.*/ + width: number; +} + +export interface MarketingFeature { + /** The marketing feature name. Up to 80 characters long.*/ + name: string | null; +} + +/** + * Deleted product response object. + */ +export interface ProductDeleted { + /** Unique identifier for the object */ + id: string; + /** String representing the object's type */ + object: 'product'; + /** Always true for a deleted object */ + deleted: true; +} diff --git a/libs/shared-types/src/lib/index.ts b/libs/shared-types/src/lib/index.ts index e0ae8b9..b8a1e54 100644 --- a/libs/shared-types/src/lib/index.ts +++ b/libs/shared-types/src/lib/index.ts @@ -13,6 +13,7 @@ export * from './ExternalWallet'; export * from './IdempotencyKey'; export * from './Payout'; export * from './Person'; +export * from './Product'; export * from './QueryParameters'; export * from './TopUp'; export * from './Transfer'; From c099dd70f715ba517a63f0318f6a34a7c649884c Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Tue, 12 May 2026 15:26:04 +0100 Subject: [PATCH 4/4] fix: missing new webhook events --- apps/web/src/app/data/services/webhook-endpoint.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/app/data/services/webhook-endpoint.service.ts b/apps/web/src/app/data/services/webhook-endpoint.service.ts index f91b36e..4754c0a 100644 --- a/apps/web/src/app/data/services/webhook-endpoint.service.ts +++ b/apps/web/src/app/data/services/webhook-endpoint.service.ts @@ -48,6 +48,9 @@ const EVENT_TYPE_LABELS: Record<(typeof EVENT_TYPES)[number], string> = { 'person.created': 'Person created', 'person.updated': 'Person updated', 'person.deleted': 'Person deleted', + 'product.created': 'Product created', + 'product.updated': 'Product updated', + 'product.deleted': 'Product deleted', 'topup.created': 'Top-up created', 'topup.canceled': 'Top-up canceled', 'topup.failed': 'Top-up failed',