mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
<!--
Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md
-->
<!-- ELLIPSIS_HIDDEN -->
----
> [!IMPORTANT]
> Enhances payments system with stackable items, Stripe account
management, and improved purchase flow, including schema updates and new
tests.
>
> - **Behavior**:
> - Adds quantity support for stackable offers in
`apps/backend/src/lib/payments.tsx` and
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`.
> - Introduces Stripe account info viewing and onboarding in
`apps/backend/src/app/api/latest/internal/payments/stripe/account-info/route.ts`.
> - Implements "Include by default" pricing and "Plans" group in
`apps/backend/prisma/seed.ts`.
> - **Schema Changes**:
> - Adds `quantity` and `offerId` columns to `Subscription` table in
`apps/backend/prisma/migrations/20250821212828_subscription_quantity/migration.sql`
and
`apps/backend/prisma/migrations/20250822203223_subscription_offer_id/migration.sql`.
> - Adds `stripeAccountId` column to `Project` table in
`apps/backend/prisma/migrations/20250825221947_stripe_account_id/migration.sql`.
> - **Improvements**:
> - Enhances purchase flow to return Stripe `client_secret` and handle
subscription upgrades/downgrades in
`apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx`.
> - Updates item management with new actions and protections in
`apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts`.
> - Tightens validation for customer type and offer conflicts in
`apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts`.
> - **Testing**:
> - Adds extensive tests for new payment features in
`apps/e2e/tests/backend/endpoints/api/v1/internal/payments/setup.test.ts`
and
`apps/e2e/tests/backend/endpoints/api/v1/payments/purchase-session.test.ts`.
> - **Misc**:
> - Removes unused `stripeAccountId` and `stripeAccountSetupComplete`
from `branchPaymentsSchema` in
`packages/stack-shared/src/config/schema.ts`.
> - Refactors currency constants into `currency-constants.tsx` in
`packages/stack-shared/src/utils`.
>
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 264563541d. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>
----
<!-- ELLIPSIS_HIDDEN -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
- **New Features**
- Quantity support for stackable offers across checkout, test purchases,
and admin flows.
- View Stripe account info and interactive payments onboarding per
project.
- "Include-by-default" pricing and new "Plans" group (Free, Extra
Admins).
- **Improvements**
- Purchase flow returns Stripe client_secret and handles group-based
subscription upgrades/downgrades.
- Item management: Update Customer Quantity action, edit/delete
protections, and read-only form mode.
- Validation surfaces offer conflicts (already_bought_non_stackable,
conflicting_group_offers).
- **Changes**
- Default item quantities now start at 0 unless explicitly granted.
- Stripe account linkage is stored per project.
- **Tests**
- Expanded tests for quantities, stackable behavior, and group
transition scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
247 lines
7.6 KiB
TypeScript
247 lines
7.6 KiB
TypeScript
import { intervalSchema } from "../schema-fields";
|
|
import { StackAssertionError } from "./errors";
|
|
import { remainder } from "./math";
|
|
|
|
export function isWeekend(date: Date): boolean {
|
|
return date.getDay() === 0 || date.getDay() === 6;
|
|
}
|
|
|
|
import.meta.vitest?.test("isWeekend", ({ expect }) => {
|
|
// Sunday (day 0)
|
|
expect(isWeekend(new Date(2023, 0, 1))).toBe(true);
|
|
// Saturday (day 6)
|
|
expect(isWeekend(new Date(2023, 0, 7))).toBe(true);
|
|
// Monday (day 1)
|
|
expect(isWeekend(new Date(2023, 0, 2))).toBe(false);
|
|
// Friday (day 5)
|
|
expect(isWeekend(new Date(2023, 0, 6))).toBe(false);
|
|
});
|
|
|
|
const agoUnits = [
|
|
[60, 'second'],
|
|
[60, 'minute'],
|
|
[24, 'hour'],
|
|
[7, 'day'],
|
|
[5, 'week'],
|
|
] as const;
|
|
|
|
export function fromNow(date: Date): string {
|
|
return fromNowDetailed(date).result;
|
|
}
|
|
|
|
import.meta.vitest?.test("fromNow", ({ expect }) => {
|
|
// Set a fixed date for testing
|
|
const fixedDate = new Date("2023-01-15T12:00:00.000Z");
|
|
|
|
// Use Vitest's fake timers
|
|
import.meta.vitest?.vi.useFakeTimers();
|
|
import.meta.vitest?.vi.setSystemTime(fixedDate);
|
|
|
|
// Test past times
|
|
expect(fromNow(new Date("2023-01-15T11:59:50.000Z"))).toBe("just now");
|
|
expect(fromNow(new Date("2023-01-15T11:59:00.000Z"))).toBe("1 minute ago");
|
|
expect(fromNow(new Date("2023-01-15T11:00:00.000Z"))).toBe("1 hour ago");
|
|
expect(fromNow(new Date("2023-01-14T12:00:00.000Z"))).toBe("1 day ago");
|
|
expect(fromNow(new Date("2023-01-08T12:00:00.000Z"))).toBe("1 week ago");
|
|
|
|
// Test future times
|
|
expect(fromNow(new Date("2023-01-15T12:00:10.000Z"))).toBe("just now");
|
|
expect(fromNow(new Date("2023-01-15T12:01:00.000Z"))).toBe("in 1 minute");
|
|
expect(fromNow(new Date("2023-01-15T13:00:00.000Z"))).toBe("in 1 hour");
|
|
expect(fromNow(new Date("2023-01-16T12:00:00.000Z"))).toBe("in 1 day");
|
|
expect(fromNow(new Date("2023-01-22T12:00:00.000Z"))).toBe("in 1 week");
|
|
|
|
// Test very old dates (should use date format)
|
|
expect(fromNow(new Date("2022-01-15T12:00:00.000Z"))).toMatch(/Jan 15, 2022/);
|
|
|
|
// Restore real timers
|
|
import.meta.vitest?.vi.useRealTimers();
|
|
});
|
|
|
|
export function fromNowDetailed(date: Date): {
|
|
result: string,
|
|
/**
|
|
* May be Infinity if the result will never change.
|
|
*/
|
|
secondsUntilChange: number,
|
|
} {
|
|
if (!(date instanceof Date)) {
|
|
throw new Error(`fromNow only accepts Date objects (received: ${date})`);
|
|
}
|
|
|
|
const now = new Date();
|
|
const elapsed = now.getTime() - date.getTime();
|
|
|
|
let remainingInUnit = Math.abs(elapsed) / 1000;
|
|
if (remainingInUnit < 15) {
|
|
return {
|
|
result: 'just now',
|
|
secondsUntilChange: 15 - remainingInUnit,
|
|
};
|
|
}
|
|
let unitInSeconds = 1;
|
|
for (const [nextUnitSize, unitName] of agoUnits) {
|
|
const rounded = Math.round(remainingInUnit);
|
|
if (rounded < nextUnitSize) {
|
|
if (elapsed < 0) {
|
|
return {
|
|
result: `in ${rounded} ${unitName}${rounded === 1 ? '' : 's'}`,
|
|
secondsUntilChange: remainder((remainingInUnit - rounded + 0.5) * unitInSeconds, unitInSeconds),
|
|
};
|
|
} else {
|
|
return {
|
|
result: `${rounded} ${unitName}${rounded === 1 ? '' : 's'} ago`,
|
|
secondsUntilChange: remainder((rounded - remainingInUnit - 0.5) * unitInSeconds, unitInSeconds),
|
|
};
|
|
}
|
|
}
|
|
unitInSeconds *= nextUnitSize;
|
|
remainingInUnit /= nextUnitSize;
|
|
}
|
|
|
|
return {
|
|
result: date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }),
|
|
secondsUntilChange: Infinity,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns a string representation of the given date in the format expected by the `datetime-local` input type.
|
|
*/
|
|
export function getInputDatetimeLocalString(date: Date): string {
|
|
date = new Date(date);
|
|
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
|
|
return date.toISOString().slice(0, 19);
|
|
}
|
|
|
|
import.meta.vitest?.test("getInputDatetimeLocalString", ({ expect }) => {
|
|
// Use Vitest's fake timers to ensure consistent timezone behavior
|
|
import.meta.vitest?.vi.useFakeTimers();
|
|
|
|
// Test with a specific date
|
|
const mockDate = new Date("2023-01-15T12:30:45.000Z");
|
|
const result = getInputDatetimeLocalString(mockDate);
|
|
|
|
// The result should be in the format YYYY-MM-DDTHH:MM:SS
|
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
|
|
|
// Test with different dates
|
|
const dates = [
|
|
new Date("2023-01-01T00:00:00.000Z"),
|
|
new Date("2023-06-15T23:59:59.000Z"),
|
|
new Date("2023-12-31T12:34:56.000Z"),
|
|
];
|
|
|
|
for (const date of dates) {
|
|
const result = getInputDatetimeLocalString(date);
|
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
|
}
|
|
|
|
// Restore real timers
|
|
import.meta.vitest?.vi.useRealTimers();
|
|
});
|
|
|
|
|
|
export type Interval = [number, 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'];
|
|
export type DayInterval = [number, 'day' | 'week' | 'month' | 'year'];
|
|
|
|
function applyInterval(date: Date, times: number, interval: Interval): Date {
|
|
if (!intervalSchema.isValidSync(interval)) {
|
|
throw new StackAssertionError(`Invalid interval`, { interval });
|
|
}
|
|
const [amount, unit] = interval;
|
|
switch (unit) {
|
|
case 'millisecond': {
|
|
date.setMilliseconds(date.getMilliseconds() + amount * times);
|
|
break;
|
|
}
|
|
case 'second': {
|
|
date.setSeconds(date.getSeconds() + amount * times);
|
|
break;
|
|
}
|
|
case 'minute': {
|
|
date.setMinutes(date.getMinutes() + amount * times);
|
|
break;
|
|
}
|
|
case 'hour': {
|
|
date.setHours(date.getHours() + amount * times);
|
|
break;
|
|
}
|
|
case 'day': {
|
|
date.setDate(date.getDate() + amount * times);
|
|
break;
|
|
}
|
|
case 'week': {
|
|
date.setDate(date.getDate() + amount * times * 7);
|
|
break;
|
|
}
|
|
case 'month': {
|
|
date.setMonth(date.getMonth() + amount * times);
|
|
break;
|
|
}
|
|
case 'year': {
|
|
date.setFullYear(date.getFullYear() + amount * times);
|
|
break;
|
|
}
|
|
default: {
|
|
throw new StackAssertionError(`Invalid interval despite schema validation`, { interval });
|
|
}
|
|
}
|
|
return date;
|
|
}
|
|
|
|
export function subtractInterval(date: Date, interval: Interval): Date {
|
|
return applyInterval(date, -1, interval);
|
|
}
|
|
|
|
export function addInterval(date: Date, interval: Interval): Date {
|
|
return applyInterval(date, 1, interval);
|
|
}
|
|
|
|
export const FAR_FUTURE_DATE = new Date(8640000000000000); // 13 Sep 275760 00:00:00 UTC
|
|
|
|
function getMsPerDayIntervalUnit(unit: 'day' | 'week'): number {
|
|
if (unit === 'day') {
|
|
return 24 * 60 * 60 * 1000;
|
|
}
|
|
return 7 * 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
|
|
export function getIntervalsElapsed(anchor: Date, to: Date, repeat: DayInterval): number {
|
|
const [amount, unit] = repeat;
|
|
if (to <= anchor) return 0;
|
|
if (unit === 'day' || unit === 'week') {
|
|
const msPerUnit = getMsPerDayIntervalUnit(unit);
|
|
const diffMs = to.getTime() - anchor.getTime();
|
|
return Math.floor(diffMs / (msPerUnit * amount));
|
|
}
|
|
if (["month", "year"].includes(unit)) {
|
|
let count = 0;
|
|
let current = new Date(anchor);
|
|
for (; ;) {
|
|
const next = addInterval(new Date(current), [amount, unit]);
|
|
if (next > to) break;
|
|
current = next;
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
import.meta.vitest?.test("getIntervalsElapsed", ({ expect }) => {
|
|
const anchor = new Date('2025-01-01T00:00:00.000Z');
|
|
const to = new Date('2025-01-15T00:00:00.000Z');
|
|
expect(getIntervalsElapsed(anchor, to, [1, 'week'])).toBe(2);
|
|
expect(getIntervalsElapsed(anchor, to, [3, 'day'])).toBe(4);
|
|
|
|
const mAnchor = new Date('2023-01-31T00:00:00.000Z');
|
|
const mTo = new Date('2023-03-01T00:00:00.000Z');
|
|
expect(getIntervalsElapsed(mAnchor, mTo, [1, 'month'])).toBe(0);
|
|
|
|
const yAnchor = new Date('2020-01-01T00:00:00.000Z');
|
|
const yTo = new Date('2022-06-01T00:00:00.000Z');
|
|
expect(getIntervalsElapsed(yAnchor, yTo, [1, 'year'])).toBe(2);
|
|
});
|