From 4149dd848ea422a32b131a4e5baf1ea466edc98a Mon Sep 17 00:00:00 2001 From: Igor Zynov Date: Tue, 29 Jul 2025 00:39:04 +0400 Subject: [PATCH] feat(payment-stripe): add support for custom payment amounts (PWYW) --- .../list/models/payment-stripe.json | 20 +++++++- .../1753535753000-add-custom-amount.ts | 19 ++++++++ .../src/entities/transaction.ts | 9 ++++ .../src/methods/price/create.ts | 6 ++- .../src/methods/stripe/create-checkout.ts | 9 +++- .../src/services/payment-gateway/abstract.ts | 2 + .../src/services/payment-gateway/stripe.ts | 47 ++++++++++++++----- 7 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 microservices/payment-stripe/migrations/1753535753000-add-custom-amount.ts diff --git a/microservices/authorization/migrations/permissions/list/models/payment-stripe.json b/microservices/authorization/migrations/permissions/list/models/payment-stripe.json index e89f41d8..3b579586 100644 --- a/microservices/authorization/migrations/permissions/list/models/payment-stripe.json +++ b/microservices/authorization/migrations/permissions/list/models/payment-stripe.json @@ -63,7 +63,15 @@ "admin": "allow" } }, - "product": "payment-stripe.Product" + "product": "payment-stripe.Product", + "metadata": { + "in": { + "admin": "allow" + }, + "out": { + "admin": "allow" + } + } }, "createdAt": "2023-05-26T13:01:39.186Z" }, @@ -306,7 +314,15 @@ } }, "customer": "payment-stripe.Customer", - "product": "payment-stripe.Product" + "product": "payment-stripe.Product", + "customAmount": { + "in": { + "admin": "allow" + }, + "out": { + "user": "allow" + } + } }, "createdAt": "2023-05-26T13:01:39.186Z" }, diff --git a/microservices/payment-stripe/migrations/1753535753000-add-custom-amount.ts b/microservices/payment-stripe/migrations/1753535753000-add-custom-amount.ts new file mode 100644 index 00000000..3ebf812b --- /dev/null +++ b/microservices/payment-stripe/migrations/1753535753000-add-custom-amount.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export default class AddCustomAmount1753535753000 implements MigrationInterface { + name = 'AddCustomAmount1753535753000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "transaction" + ADD COLUMN "customAmount" integer DEFAULT null + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "transaction" + DROP COLUMN "customAmount" + `); + } +} diff --git a/microservices/payment-stripe/src/entities/transaction.ts b/microservices/payment-stripe/src/entities/transaction.ts index 84a99b73..19d044cc 100644 --- a/microservices/payment-stripe/src/entities/transaction.ts +++ b/microservices/payment-stripe/src/entities/transaction.ts @@ -215,6 +215,15 @@ class Transaction { @IsNumber() amount: number; + @JSONSchema({ + description: 'Custom amount paid by user for PWYW transactions (in cents)', + }) + @Column({ type: 'int', default: null }) + @IsUndefinable() + @IsNullable() + @IsNumber() + customAmount: number | null; + @JSONSchema({ description: `Sales tax or other, that should be paid to the government by tax collector. Tax included in the payment intent amount and storing as collected fees amount.`, diff --git a/microservices/payment-stripe/src/methods/price/create.ts b/microservices/payment-stripe/src/methods/price/create.ts index cbb87175..c60cc90d 100644 --- a/microservices/payment-stripe/src/methods/price/create.ts +++ b/microservices/payment-stripe/src/methods/price/create.ts @@ -1,6 +1,6 @@ import { Endpoint } from '@lomray/microservice-helpers'; import { Type } from 'class-transformer'; -import { IsNumber, IsObject, IsString } from 'class-validator'; +import { IsNumber, IsObject, IsString, IsOptional } from 'class-validator'; import Price from '@entities/price'; import Stripe from '@services/payment-gateway/stripe'; @@ -16,6 +16,10 @@ class PriceCreateInput { @IsNumber() unitAmount: number; + + @IsObject() + @IsOptional() + metadata?: Record; } class PriceCreateOutput { diff --git a/microservices/payment-stripe/src/methods/stripe/create-checkout.ts b/microservices/payment-stripe/src/methods/stripe/create-checkout.ts index 3e7e0064..9f9ceac2 100644 --- a/microservices/payment-stripe/src/methods/stripe/create-checkout.ts +++ b/microservices/payment-stripe/src/methods/stripe/create-checkout.ts @@ -1,5 +1,5 @@ import { Endpoint, IsNullable, IsUndefinable } from '@lomray/microservice-helpers'; -import { IsBoolean, IsString, Length } from 'class-validator'; +import { IsBoolean, IsNumber, IsString, Length } from 'class-validator'; import Stripe from '@services/payment-gateway/stripe'; class CreateCheckoutInput { @@ -19,6 +19,10 @@ class CreateCheckoutInput { @IsBoolean() @IsUndefinable() isAllowPromoCode?: boolean; + + @IsNumber() + @IsUndefinable() + customAmount?: number; } class CreateCheckoutOutput { @@ -36,7 +40,7 @@ const createCheckout = Endpoint.custom( output: CreateCheckoutOutput, description: 'Setup intent and return client secret key', }), - async ({ priceId, successUrl, cancelUrl, userId, isAllowPromoCode }) => { + async ({ priceId, successUrl, cancelUrl, userId, isAllowPromoCode, customAmount }) => { const service = await Stripe.init(); return { @@ -46,6 +50,7 @@ const createCheckout = Endpoint.custom( successUrl, cancelUrl, isAllowPromoCode, + customAmount, }), }; }, diff --git a/microservices/payment-stripe/src/services/payment-gateway/abstract.ts b/microservices/payment-stripe/src/services/payment-gateway/abstract.ts index b475c350..7014f46b 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/abstract.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/abstract.ts @@ -41,6 +41,7 @@ export interface IPriceParams { userId: string; currency: string; unitAmount: number; + metadata?: Record; } export interface ITransactionParams { @@ -56,6 +57,7 @@ export interface ITransactionParams { tax?: number; fee?: number; params?: ITransactionEntityParams; + customAmount?: number; } export interface IProductParams { diff --git a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts index c8d54ff2..39978fc2 100644 --- a/microservices/payment-stripe/src/services/payment-gateway/stripe.ts +++ b/microservices/payment-stripe/src/services/payment-gateway/stripe.ts @@ -99,6 +99,7 @@ interface ICheckoutParams { successUrl: string; cancelUrl: string; isAllowPromoCode?: boolean; + customAmount?: number; } interface ICheckoutEvent { @@ -369,16 +370,17 @@ class Stripe extends Abstract { } /** - * Create Price entity + * Create new price */ public async createPrice(params: IPriceParams): Promise { - const { currency, unitAmount, productId, userId } = params; + const { currency, unitAmount, productId, userId, metadata } = params; const { id }: StripeSdk.Price = await this.sdk.prices.create({ currency, product: productId, // eslint-disable-next-line camelcase unit_amount: unitAmount, + ...(metadata ? { metadata } : {}), }); return super.createPrice( @@ -396,7 +398,7 @@ class Stripe extends Abstract { * Create checkout session and return url to redirect user for payment */ public async createCheckout(params: ICheckoutParams): Promise { - const { priceId, userId, successUrl, cancelUrl, isAllowPromoCode } = params; + const { priceId, userId, successUrl, cancelUrl, isAllowPromoCode, customAmount } = params; const { customerId } = await super.getCustomer(userId); const price = await this.priceRepository.findOne({ priceId }, { relations: ['product'] }); @@ -408,29 +410,48 @@ class Stripe extends Abstract { } /* eslint-disable camelcase */ - const { id, url } = await this.sdk.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], + const sessionParams: StripeSdk.Checkout.SessionCreateParams = { mode: 'payment', customer: customerId, success_url: successUrl, cancel_url: cancelUrl, - allow_promotion_codes: isAllowPromoCode, - }); + allow_promotion_codes: isAllowPromoCode && !customAmount, // No promo codes for PWYW + }; + + if (customAmount) { + // PWYW mode - create custom line item with user-defined amount + sessionParams.line_items = [ + { + price_data: { + currency: 'usd', + product: price.productId, + unit_amount: customAmount * 100, // Convert to cents + }, + quantity: 1, + }, + ]; + } else { + // Fixed price mode + sessionParams.line_items = [ + { + price: priceId, + quantity: 1, + }, + ]; + } + + const { id, url } = await this.sdk.checkout.sessions.create(sessionParams); /* eslint-enable camelcase */ await this.createTransaction( { type: TransactionType.CREDIT, - amount: price.unitAmount, + amount: customAmount ? customAmount * 100 : price.unitAmount, // Store in cents userId, productId: price.productId, entityId: price.product.entityId, status: TransactionStatus.INITIAL, + customAmount: customAmount ? customAmount * 100 : undefined, // Store custom amount in cents }, id, );