v1.3.27-c: Add adjustment/summary action for meter event

This commit is contained in:
Yudi Xiao
2025-10-14 10:42:41 +08:00
parent 87846d7668
commit 0705a5f74d
4 changed files with 1792 additions and 1677 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "@bowong/better-auth-stripe", "name": "@bowong/better-auth-stripe",
"author": "Bowong", "author": "Bowong",
"version": "1.3.27-a", "version": "1.3.27-c",
"main": "dist/index.cjs", "main": "dist/index.cjs",
"license": "MIT", "license": "MIT",
"keywords": [ "keywords": [

View File

@@ -3,9 +3,9 @@ import {
type BetterAuthPlugin, type BetterAuthPlugin,
logger, logger,
} from "better-auth"; } from "better-auth";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/plugins"; import {createAuthEndpoint, createAuthMiddleware} from "better-auth/plugins";
import Stripe from "stripe"; import Stripe from "stripe";
import { type Stripe as StripeType } from "stripe"; import {type Stripe as StripeType} from "stripe";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { import {
sessionMiddleware, sessionMiddleware,
@@ -19,14 +19,15 @@ import {
onSubscriptionUpdated, onSubscriptionUpdated,
} from "./hooks"; } from "./hooks";
import type { import type {
actionType,
InputSubscription, InputSubscription,
StripeOptions, StripeOptions,
StripePlan, StripePlan,
Subscription, Subscription,
} from "./types"; } from "./types";
import { getPlanByName, getPlanByPriceInfo, getPlans } from "./utils"; import {getPlanByName, getPlanByPriceInfo, getPlans} from "./utils";
import { getSchema } from "./schema"; import {getSchema} from "./schema";
import { defu } from "defu"; import {defu} from "defu";
const STRIPE_ERROR_CODES = { const STRIPE_ERROR_CODES = {
SUBSCRIPTION_NOT_FOUND: "Subscription not found", SUBSCRIPTION_NOT_FOUND: "Subscription not found",
@@ -67,13 +68,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
const client = options.stripeClient; const client = options.stripeClient;
const referenceMiddleware = ( const referenceMiddleware = (
action: action: actionType,
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
| "restore-subscription"
| "meter-event"
| "billing-portal",
) => ) =>
createAuthMiddleware(async (ctx) => { createAuthMiddleware(async (ctx) => {
const session = ctx.context.session; const session = ctx.context.session;
@@ -235,7 +230,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
], ],
}, },
async (ctx) => { async (ctx) => {
const { user, session } = ctx.context.session; const {user, session} = ctx.context.session;
if ( if (
!user.emailVerified && !user.emailVerified &&
options.subscription?.requireEmailVerification options.subscription?.requireEmailVerification
@@ -270,7 +265,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
: referenceId : referenceId
? await ctx.context.adapter.findOne<Subscription>({ ? await ctx.context.adapter.findOne<Subscription>({
model: "subscription", model: "subscription",
where: [{ field: "referenceId", value: referenceId }], where: [{field: "referenceId", value: referenceId}],
}) })
: null; : null;
@@ -443,7 +438,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
}); });
} }
const { url } = await client.billingPortal.sessions const {url} = await client.billingPortal.sessions
.create({ .create({
customer: customerId, customer: customerId,
return_url: getUrl(ctx, ctx.body.returnUrl || "/"), return_url: getUrl(ctx, ctx.body.returnUrl || "/"),
@@ -543,7 +538,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
const freeTrial = const freeTrial =
!hasEverTrialed && plan.freeTrial !hasEverTrialed && plan.freeTrial
? { trial_period_days: plan.freeTrial.days } ? {trial_period_days: plan.freeTrial.days}
: undefined; : undefined;
let priceIdToUse: string | undefined = undefined; let priceIdToUse: string | undefined = undefined;
@@ -637,8 +632,8 @@ export const stripe = <O extends StripeOptions>(options: O) => {
if (!session) { if (!session) {
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
} }
const { user } = session; const {user} = session;
const { callbackURL, subscriptionId } = ctx.query; const {callbackURL, subscriptionId} = ctx.query;
if (user?.stripeCustomerId) { if (user?.stripeCustomerId) {
try { try {
@@ -759,7 +754,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
: await ctx.context.adapter : await ctx.context.adapter
.findMany<Subscription>({ .findMany<Subscription>({
model: "subscription", model: "subscription",
where: [{ field: "referenceId", value: referenceId }], where: [{field: "referenceId", value: referenceId}],
}) })
.then((subs) => .then((subs) =>
subs.find( subs.find(
@@ -807,7 +802,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND, message: STRIPE_ERROR_CODES.SUBSCRIPTION_NOT_FOUND,
}); });
} }
const { url } = await client.billingPortal.sessions const {url} = await client.billingPortal.sessions
.create({ .create({
customer: subscription.stripeCustomerId, customer: subscription.stripeCustomerId,
return_url: getUrl( return_url: getUrl(
@@ -1058,8 +1053,8 @@ export const stripe = <O extends StripeOptions>(options: O) => {
if (!session) { if (!session) {
throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/")); throw ctx.redirect(getUrl(ctx, ctx.query?.callbackURL || "/"));
} }
const { user } = session; const {user} = session;
const { callbackURL, subscriptionId } = ctx.query; const {callbackURL, subscriptionId} = ctx.query;
const subscription = await ctx.context.adapter.findOne<Subscription>({ const subscription = await ctx.context.adapter.findOne<Subscription>({
model: "subscription", model: "subscription",
@@ -1163,7 +1158,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
], ],
}, },
async (ctx) => { async (ctx) => {
const { user } = ctx.context.session; const {user} = ctx.context.session;
const referenceId = ctx.body.referenceId || user.id; const referenceId = ctx.body.referenceId || user.id;
let customerId = user.stripeCustomerId; let customerId = user.stripeCustomerId;
@@ -1195,7 +1190,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
} }
try { try {
const { url } = await client.billingPortal.sessions.create({ const {url} = await client.billingPortal.sessions.create({
locale: ctx.body.locale, locale: ctx.body.locale,
customer: customerId, customer: customerId,
return_url: getUrl(ctx, ctx.body.returnUrl), return_url: getUrl(ctx, ctx.body.returnUrl),
@@ -1221,7 +1216,6 @@ export const stripe = <O extends StripeOptions>(options: O) => {
{ {
method: "POST", method: "POST",
body: z.object({ body: z.object({
meter_id: z.string(),
event_name: z.string(), event_name: z.string(),
payload: z.object({ payload: z.object({
value: z.string(), value: z.string(),
@@ -1235,7 +1229,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
] ]
}, },
async (ctx) => { async (ctx) => {
const { user } = ctx.context.session; const {user} = ctx.context.session;
let customerId = user.stripeCustomerId; let customerId = user.stripeCustomerId;
const referenceId = ctx.body.referenceId || user.id; const referenceId = ctx.body.referenceId || user.id;
@@ -1265,17 +1259,17 @@ export const stripe = <O extends StripeOptions>(options: O) => {
}); });
} }
const submitPayload = { try {
const bodyPayload = {
event_name: ctx.body.event_name,
payload: {
value: ctx.body.payload.value, value: ctx.body.payload.value,
stripe_customer_id: customerId, stripe_customer_id: customerId,
},
identifier: ctx.body.identifier
} }
try { const {identifier, payload, timestamp} = await client.v2.billing.meterEvents.create(bodyPayload);
const {identifier, payload, timestamp} = await client.billing.meterEvents.create({
event_name: ctx.body.event_name,
payload: submitPayload,
identifier: ctx.body.identifier
});
return ctx.json({ return ctx.json({
identifier, identifier,
@@ -1293,6 +1287,122 @@ export const stripe = <O extends StripeOptions>(options: O) => {
} }
} }
), ),
adjustBillingMeterEvent: createAuthEndpoint(
"/subscription/meter-event/adjust", // https://docs.stripe.com/api/v2/billing-meter-adjustment
{
method: "POST",
body: z.object({
event_name: z.string(),
type: z.enum(["cancel"]),
cancel: z.object({
identifier: z.string(),
}),
}),
use: [sessionMiddleware, referenceMiddleware("adjust-meter-event")],
},
async (ctx) => {
const bodyPayload = ctx.body
try {
const result = await client.v2.billing.meterEventAdjustments.create(bodyPayload);
return ctx.json(result);
} catch (error: any) {
ctx.context.logger.error(
"Error submitting meter event",
error,
);
throw new APIError("BAD_REQUEST", {
message: error.message,
});
}
}
),
summaryBillingMeterEvent: createAuthEndpoint(
"/subscription/meter-event/summary", // https://docs.stripe.com/api/billing/meter-event-summary
{
method: "GET",
query: z.object({
id: z.string().meta({
description: "The id of the meter event, ie: 'mtr_test_61Q8nQMqIFK9fRQmr41CMAXJrFdZ5MnA'"
}),
start_time: z.number().meta({
description: "Unix timestamp in seconds"
}),
end_time: z.number().meta({
description: "Unix timestamp in seconds"
}),
value_grouping_window: z.enum(['day', 'hour']),
ending_before: z.string().optional().meta({
description: "A cursor for use in pagination"
}),
starting_after: z.string().optional().meta({
description: "A cursor for use in pagination"
}),
limit: z.number().optional().meta({
description: "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10."
})
}),
use: [
sessionMiddleware,
referenceMiddleware("meter-event-summary"),
]
},
async (ctx) => {
const {user} = ctx.context.session;
let customerId = user.stripeCustomerId;
const referenceId = ctx.body.referenceId || user.id;
if (!customerId) {
const subscription = await ctx.context.adapter
.findMany<Subscription>({
model: "subscription",
where: [
{
field: "referenceId",
value: referenceId,
},
],
})
.then((subs) =>
subs.find(
(sub) => sub.status === "active" || sub.status === "trialing",
),
);
customerId = subscription?.stripeCustomerId;
}
if (!customerId) {
throw new APIError("BAD_REQUEST", {
message: "No Stripe customer found for this user",
});
}
try {
const result = await client.billing.meters.listEventSummaries(
ctx.query.id,
{
customer: customerId,
start_time: ctx.query.start_time,
end_time: ctx.query.end_time,
limit: ctx.query.limit,
starting_after: ctx.query.starting_after,
ending_before: ctx.query.ending_before,
}
);
return ctx.json(result);
} catch (error: any) {
ctx.context.logger.error(
"Error submitting meter event",
error,
);
throw new APIError("BAD_REQUEST", {
message: error.message,
});
}
}
)
} as const; } as const;
return { return {
id: "stripe", id: "stripe",
@@ -1364,7 +1474,7 @@ export const stripe = <O extends StripeOptions>(options: O) => {
message: "Webhook error: See server logs for more information.", message: "Webhook error: See server logs for more information.",
}); });
} }
return ctx.json({ success: true }); return ctx.json({success: true});
}, },
), ),
...((options.subscription?.enabled ...((options.subscription?.enabled
@@ -1479,4 +1589,4 @@ export const stripe = <O extends StripeOptions>(options: O) => {
} satisfies BetterAuthPlugin; } satisfies BetterAuthPlugin;
}; };
export type { Subscription, StripePlan }; export type {Subscription, StripePlan};

View File

@@ -5,7 +5,7 @@ import type {
User, User,
} from "better-auth"; } from "better-auth";
import type Stripe from "stripe"; import type Stripe from "stripe";
import type { subscriptions, user } from "./schema"; import type {subscriptions, user} from "./schema";
export type StripePlan = { export type StripePlan = {
/** /**
@@ -90,6 +90,16 @@ export type StripePlan = {
}; };
}; };
export type actionType =
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
| "restore-subscription"
| "meter-event"
| "adjust-meter-event"
| "meter-event-summary"
| "billing-portal";
export interface Subscription { export interface Subscription {
/** /**
* Database identifier * Database identifier
@@ -267,13 +277,7 @@ export interface StripeOptions {
user: User & Record<string, any>; user: User & Record<string, any>;
session: Session & Record<string, any>; session: Session & Record<string, any>;
referenceId: string; referenceId: string;
action: action: actionType;
| "upgrade-subscription"
| "list-subscription"
| "cancel-subscription"
| "restore-subscription"
| "meter-event"
| "billing-portal";
}, },
ctx: GenericEndpointContext, ctx: GenericEndpointContext,
) => Promise<boolean>; ) => Promise<boolean>;
@@ -328,4 +332,5 @@ export interface StripeOptions {
schema?: InferOptionSchema<typeof subscriptions & typeof user>; schema?: InferOptionSchema<typeof subscriptions & typeof user>;
} }
export interface InputSubscription extends Omit<Subscription, "id"> {} export interface InputSubscription extends Omit<Subscription, "id"> {
}

View File

@@ -1,4 +1,4 @@
import type { StripeOptions } from "./types"; import type {StripeOptions} from "./types";
export async function getPlans(options: StripeOptions) { export async function getPlans(options: StripeOptions) {
return typeof options?.subscription?.plans === "function" return typeof options?.subscription?.plans === "function"