@softure-ai/billing 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +75 -16
- package/dist/contract.d.ts +19 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +1 -0
- package/dist/contract.js.map +1 -1
- package/dist/fields.d.ts +2 -0
- package/dist/fields.d.ts.map +1 -1
- package/dist/fields.js +2 -0
- package/dist/fields.js.map +1 -1
- package/dist/index.d.ts +19 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +17 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +17 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +36 -2
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/index.js.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +17 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts +8 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +37 -1
- package/dist/next/actions.js.map +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +1 -1
- package/dist/next/index.js.map +1 -1
- package/dist/next/pages.d.ts +13 -3
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +25 -8
- package/dist/next/pages.js.map +1 -1
- package/dist/schema.d.ts +109 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +10 -2
- package/dist/schema.js.map +1 -1
- package/dist/scripts/entitlement-scripts.d.ts +2 -0
- package/dist/scripts/entitlement-scripts.d.ts.map +1 -1
- package/dist/scripts/entitlement-scripts.js +17 -5
- package/dist/scripts/entitlement-scripts.js.map +1 -1
- package/dist/server/entitlements.d.ts +23 -6
- package/dist/server/entitlements.d.ts.map +1 -1
- package/dist/server/entitlements.js +37 -6
- package/dist/server/entitlements.js.map +1 -1
- package/dist/server/grants.d.ts +13 -2
- package/dist/server/grants.d.ts.map +1 -1
- package/dist/server/grants.js +11 -2
- package/dist/server/grants.js.map +1 -1
- package/dist/server/health.d.ts.map +1 -1
- package/dist/server/health.js +2 -1
- package/dist/server/health.js.map +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/privacy.d.ts +9 -0
- package/dist/server/privacy.d.ts.map +1 -1
- package/dist/server/privacy.js +19 -6
- package/dist/server/privacy.js.map +1 -1
- package/dist/server/trials.d.ts +22 -0
- package/dist/server/trials.d.ts.map +1 -0
- package/dist/server/trials.js +60 -0
- package/dist/server/trials.js.map +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/trial-form.d.ts +16 -0
- package/dist/ui/trial-form.d.ts.map +1 -0
- package/dist/ui/trial-form.js +25 -0
- package/dist/ui/trial-form.js.map +1 -0
- package/migrations/0010_record_trial_extensions_and_index_foreign_keys.sql +27 -0
- package/module.json +2 -2
- package/package.json +1 -1
- package/src/contract.ts +28 -0
- package/src/fields.ts +2 -0
- package/src/index.ts +7 -3
- package/src/messages/en.ts +17 -0
- package/src/messages/index.ts +2 -2
- package/src/messages/pl.ts +17 -0
- package/src/next/actions.ts +35 -2
- package/src/next/index.ts +9 -1
- package/src/next/pages.tsx +41 -6
- package/src/schema.ts +11 -2
- package/src/scripts/entitlement-scripts.ts +18 -5
- package/src/server/entitlements.ts +60 -6
- package/src/server/grants.ts +24 -3
- package/src/server/health.ts +2 -1
- package/src/server/index.ts +3 -0
- package/src/server/privacy.ts +29 -6
- package/src/server/trials.ts +70 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/trial-form.tsx +80 -0
package/src/next/actions.ts
CHANGED
|
@@ -11,14 +11,16 @@ import { errorLogLabel, formatMessage, safeError, type CoreErrorCode, type Err,
|
|
|
11
11
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
12
12
|
import { revalidatePath } from "next/cache";
|
|
13
13
|
import { redirect } from "next/navigation";
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
14
|
+
import { getStartOfDay, parseDay } from "../calendar.js";
|
|
15
|
+
import type { AdminActionErrorCode, AdminActionState, GrantFormState, PaymentFormState, TrialFormState } from "../contract.js";
|
|
16
|
+
import { ACCOUNT_PARAM, EMAIL_FIELD, GRANT_FIELD, INVOICE_FIELDS, PLAN_FIELD, REQUEST_FIELD, TRIAL_LAST_DAY_FIELD } from "../fields.js";
|
|
16
17
|
import { findPlan, getLocalizedText } from "../plans.js";
|
|
17
18
|
import { grantPaymentRequest, grantPlanManually, revokeManualGrant } from "../server/grants.js";
|
|
18
19
|
import { getBillingMessages, getBillingOptions, getBillingRoutes } from "../server/options.js";
|
|
19
20
|
import { findAccountByEmail, getBillingPlans, startPayment } from "../server/plans.js";
|
|
20
21
|
import type { BillingContext } from "../server/entitlements.js";
|
|
21
22
|
import { dismissPaymentRequest } from "../server/requests.js";
|
|
23
|
+
import { extendTrialManually } from "../server/trials.js";
|
|
22
24
|
import { formatLastDay } from "../ui/format.js";
|
|
23
25
|
import { getBillingContext } from "./context.js";
|
|
24
26
|
|
|
@@ -107,6 +109,37 @@ export async function grantPlanAction(_previous: GrantFormState, formData: FormD
|
|
|
107
109
|
return { status: "error", error, ...echo };
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Extends the trial of the account with the form's email through the form's last day (the trial
|
|
114
|
+
* ends at the start of the next day in the app's time zone) and records it in the account's history.
|
|
115
|
+
* Only for the role of `billing({ adminRole })`, checked from the session before the form is read.
|
|
116
|
+
* Never writes paid access.
|
|
117
|
+
*/
|
|
118
|
+
export async function extendTrialAction(_previous: TrialFormState, formData: FormData): Promise<TrialFormState> {
|
|
119
|
+
const config = getSoftureConfig();
|
|
120
|
+
const admin = await authorizeAdmin(config);
|
|
121
|
+
if (!admin.ok) return { status: "error", error: admin.error };
|
|
122
|
+
const email = readText(formData, EMAIL_FIELD).trim();
|
|
123
|
+
const lastDay = readText(formData, TRIAL_LAST_DAY_FIELD).trim();
|
|
124
|
+
const echo = { email, lastDay };
|
|
125
|
+
|
|
126
|
+
const day = parseDay(lastDay);
|
|
127
|
+
if (day === null) return { status: "error", error: "billing.day_invalid", ...echo };
|
|
128
|
+
const until = getStartOfDay(day + 1, config.timezone);
|
|
129
|
+
try {
|
|
130
|
+
const ctx = await getBillingContext(config);
|
|
131
|
+
const account = await findAccountByEmail(ctx, email);
|
|
132
|
+
if (account === null) return { status: "error", error: "billing.account_unknown", ...echo };
|
|
133
|
+
const result = await extendTrialManually(ctx, { userId: account.id, until, adminId: admin.value.id });
|
|
134
|
+
if (!result.ok) return { status: "error", error: result.error, ...echo };
|
|
135
|
+
refreshAdminPage(config);
|
|
136
|
+
const notice = formatMessage(getBillingMessages(config).admin.trial.extended, { email: account.email, date: formatLastDay(until, config.locale, config.timezone) });
|
|
137
|
+
return { status: "extended", notice };
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { status: "error", error: reportFailure("extending a trial", error), ...echo };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
110
143
|
interface AdminChange {
|
|
111
144
|
readonly operation: string;
|
|
112
145
|
/** The form field that names the row. */
|
package/src/next/index.ts
CHANGED
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// their actions, and the Stripe webhook route, wired to the registered configuration
|
|
4
4
|
// (docs/02-module-standard.md §8).
|
|
5
5
|
export { CurrentAccessBadge, type CurrentAccessBadgeProps, CurrentAccessNotice, type CurrentAccessNoticeProps } from "./access.js";
|
|
6
|
-
export {
|
|
6
|
+
export {
|
|
7
|
+
dismissRequestAction,
|
|
8
|
+
extendTrialAction,
|
|
9
|
+
findAccountAction,
|
|
10
|
+
grantPlanAction,
|
|
11
|
+
grantRequestAction,
|
|
12
|
+
revokeGrantAction,
|
|
13
|
+
startPaymentAction,
|
|
14
|
+
} from "./actions.js";
|
|
7
15
|
export { getBillingContext } from "./context.js";
|
|
8
16
|
export { getCurrentEntitlement, requireWriteAccess, type WriteAccess } from "./current-entitlement.js";
|
|
9
17
|
export { BillingAdminPage, type BillingAdminPageProps, PaymentPage, type PaymentPageProps } from "./pages.js";
|
package/src/next/pages.tsx
CHANGED
|
@@ -23,8 +23,9 @@ import { AccountLookup, GrantHistory, type GrantHistoryRow } from "../ui/grant-h
|
|
|
23
23
|
import { PaymentForm } from "../ui/payment-form.js";
|
|
24
24
|
import { PaymentRequestList, type PaymentRequestRow } from "../ui/payment-requests.js";
|
|
25
25
|
import { PricingTiles } from "../ui/pricing-tiles.js";
|
|
26
|
+
import { TrialForm } from "../ui/trial-form.js";
|
|
26
27
|
import { CurrentAccessBadge } from "./access.js";
|
|
27
|
-
import { dismissRequestAction, findAccountAction, grantPlanAction, grantRequestAction, revokeGrantAction, startPaymentAction } from "./actions.js";
|
|
28
|
+
import { dismissRequestAction, extendTrialAction, findAccountAction, grantPlanAction, grantRequestAction, revokeGrantAction, startPaymentAction } from "./actions.js";
|
|
28
29
|
import { getBillingContext } from "./context.js";
|
|
29
30
|
import { getCurrentEntitlement } from "./current-entitlement.js";
|
|
30
31
|
import { getPlanPaymentHref } from "./pricing.js";
|
|
@@ -33,10 +34,16 @@ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
|
33
34
|
|
|
34
35
|
export interface PaymentPageProps {
|
|
35
36
|
readonly searchParams?: SearchParams;
|
|
37
|
+
/**
|
|
38
|
+
* The page's `<h1>`, first in `<main>`: `payment.heading` of the messages by default, this text
|
|
39
|
+
* instead, or none with `null` (for an app whose frame renders the page heading).
|
|
40
|
+
*/
|
|
41
|
+
readonly heading?: string | null;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:flex sft:w-full sft:flex-col sft:gap-4 sft:sm:max-w-md sft:px-4 sft:py-4";
|
|
39
45
|
const STACK_CLASS = "sft:flex sft:flex-col sft:gap-4";
|
|
46
|
+
const HEADING_CLASS = "sft:m-0 sft:font-heading sft:text-2xl sft:font-bold sft:text-foreground";
|
|
40
47
|
const LEAD_CLASS = "sft:m-0 sft:font-sans sft:text-sm sft:text-muted";
|
|
41
48
|
const NOTICE_CLASS =
|
|
42
49
|
"sft:m-0 sft:rounded-control sft:border sft:border-border-strong sft:bg-surface-raised sft:px-4 sft:py-2.5 sft:font-sans sft:text-sm sft:text-foreground";
|
|
@@ -57,7 +64,7 @@ function isCheckoutResult(value: string | undefined): value is CheckoutResult {
|
|
|
57
64
|
* A hosted checkout comes back with `?checkout=success` or `?checkout=cancelled`, shown as a notice
|
|
58
65
|
* (the access itself changes when the provider's webhook confirms the payment).
|
|
59
66
|
*/
|
|
60
|
-
export async function PaymentPage({ searchParams }: PaymentPageProps) {
|
|
67
|
+
export async function PaymentPage({ searchParams, heading }: PaymentPageProps) {
|
|
61
68
|
const config = getSoftureConfig();
|
|
62
69
|
const route = getBillingRoutes(config).payment;
|
|
63
70
|
const planId = await readParam(searchParams, PLAN_FIELD);
|
|
@@ -75,6 +82,7 @@ export async function PaymentPage({ searchParams }: PaymentPageProps) {
|
|
|
75
82
|
const hasLifetime = entitlement?.status === "paid" && entitlement.endsAt === null;
|
|
76
83
|
return (
|
|
77
84
|
<main className={LAYOUT_CLASS}>
|
|
85
|
+
<PageHeading text={heading === undefined ? copy.heading : heading} />
|
|
78
86
|
<Card title={copy.title} subtitle={copy.lead}>
|
|
79
87
|
<div className={STACK_CLASS}>
|
|
80
88
|
{checkoutNotice === null ? null : (
|
|
@@ -128,6 +136,11 @@ export async function PaymentPage({ searchParams }: PaymentPageProps) {
|
|
|
128
136
|
);
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
/** The page's `<h1>`, or nothing for `null`. */
|
|
140
|
+
function PageHeading({ text }: { readonly text: string | null }) {
|
|
141
|
+
return text === null ? null : <h1 className={HEADING_CLASS}>{text}</h1>;
|
|
142
|
+
}
|
|
143
|
+
|
|
131
144
|
/** The plan's name in the app's locale, or its id when the config no longer has it. */
|
|
132
145
|
function getPlanName(plans: readonly Plan[], planId: string, locale: Locale): string {
|
|
133
146
|
const plan = findPlan(plans, planId);
|
|
@@ -138,6 +151,8 @@ interface RowContext {
|
|
|
138
151
|
readonly config: SoftureConfig;
|
|
139
152
|
readonly messages: BillingMessages;
|
|
140
153
|
readonly plans: readonly Plan[];
|
|
154
|
+
/** The page's instant, to tell a trial extension still running from an ended one. */
|
|
155
|
+
readonly now: Date;
|
|
141
156
|
}
|
|
142
157
|
|
|
143
158
|
function getHistoryHref(config: SoftureConfig, userId: string): string {
|
|
@@ -175,8 +190,19 @@ function describeGrant(grant: PaymentGrant | null, { config, messages }: RowCont
|
|
|
175
190
|
function toHistoryRow(entry: AccountHistoryEntry, context: RowContext): GrantHistoryRow {
|
|
176
191
|
const { config, messages, plans } = context;
|
|
177
192
|
const copy = messages.admin.history;
|
|
178
|
-
const plan = getPlanName(plans, entry.planId, config.locale);
|
|
179
193
|
const formatDate = (date: Date) => formatDay(date, config.locale, config.timezone);
|
|
194
|
+
if (entry.source === "trial") {
|
|
195
|
+
const formatEnd = (end: Date) => formatLastDay(end, config.locale, config.timezone);
|
|
196
|
+
return {
|
|
197
|
+
id: entry.id,
|
|
198
|
+
title: copy.trialExtended,
|
|
199
|
+
statusText: formatMessage(copy.trialUntil, { date: formatEnd(entry.endsAt) }),
|
|
200
|
+
isCurrent: entry.endsAt > context.now,
|
|
201
|
+
details: [formatMessage(copy.extendedOn, { date: formatDate(entry.at) }), formatMessage(copy.trialWasUntil, { date: formatEnd(entry.previousEndsAt) })],
|
|
202
|
+
revokeLabel: null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const plan = getPlanName(plans, entry.planId, config.locale);
|
|
180
206
|
if (entry.source === "manual") {
|
|
181
207
|
const isActive = entry.status === "active";
|
|
182
208
|
return {
|
|
@@ -213,21 +239,26 @@ function toHistoryRow(entry: AccountHistoryEntry, context: RowContext): GrantHis
|
|
|
213
239
|
|
|
214
240
|
export interface BillingAdminPageProps {
|
|
215
241
|
readonly searchParams?: SearchParams;
|
|
242
|
+
/**
|
|
243
|
+
* The page's `<h1>`, first in `<main>`: `admin.heading` of the messages by default, this text
|
|
244
|
+
* instead, or none with `null` (for an app whose frame renders the page heading).
|
|
245
|
+
*/
|
|
246
|
+
readonly heading?: string | null;
|
|
216
247
|
}
|
|
217
248
|
|
|
218
249
|
/**
|
|
219
250
|
* The admin page of manual payments: the open invoice requests (grant or dismiss each), the grant
|
|
220
|
-
* form, and an account's history (`?account=<id>`, reached by the email lookup or a request's
|
|
251
|
+
* form, the trial form (a free, longer trial), and an account's history (`?account=<id>`, reached by the email lookup or a request's
|
|
221
252
|
* link) with its access and a revoke button on each active manual grant. Anyone without the role
|
|
222
253
|
* of `billing({ adminRole })`, signed in or not, gets Next's "not found".
|
|
223
254
|
*/
|
|
224
|
-
export async function BillingAdminPage({ searchParams }: BillingAdminPageProps) {
|
|
255
|
+
export async function BillingAdminPage({ searchParams, heading }: BillingAdminPageProps) {
|
|
225
256
|
const config = getSoftureConfig();
|
|
226
257
|
await requireRole(getBillingOptions(config).adminRole);
|
|
227
258
|
const messages = getBillingMessages(config);
|
|
228
259
|
const plans = getBillingPlans(config);
|
|
229
|
-
const context: RowContext = { config, messages, plans };
|
|
230
260
|
const ctx = await getBillingContext(config);
|
|
261
|
+
const context: RowContext = { config, messages, plans, now: ctx.clock.now() };
|
|
231
262
|
const requests = (await listOpenRequests(ctx)).map((request) => toRequestRow(request, context));
|
|
232
263
|
const accountId = await readParam(searchParams, ACCOUNT_PARAM);
|
|
233
264
|
const account = accountId === undefined ? null : await findAccountById(ctx, accountId);
|
|
@@ -236,6 +267,7 @@ export async function BillingAdminPage({ searchParams }: BillingAdminPageProps)
|
|
|
236
267
|
const planOptions = plans.map((plan) => ({ value: plan.id, label: getLocalizedText(plan.name, config.locale) }));
|
|
237
268
|
return (
|
|
238
269
|
<main className={LAYOUT_CLASS}>
|
|
270
|
+
<PageHeading text={heading === undefined ? messages.admin.heading : heading} />
|
|
239
271
|
<Card title={messages.admin.requests.title} subtitle={messages.admin.requests.lead}>
|
|
240
272
|
<PaymentRequestList requests={requests} grantAction={grantRequestAction} dismissAction={dismissRequestAction} messages={messages} />
|
|
241
273
|
</Card>
|
|
@@ -246,6 +278,9 @@ export async function BillingAdminPage({ searchParams }: BillingAdminPageProps)
|
|
|
246
278
|
<GrantForm action={grantPlanAction} plans={planOptions} messages={messages} locale={config.locale} />
|
|
247
279
|
)}
|
|
248
280
|
</Card>
|
|
281
|
+
<Card title={messages.admin.trial.title} subtitle={messages.admin.trial.lead}>
|
|
282
|
+
<TrialForm action={extendTrialAction} messages={messages} locale={config.locale} />
|
|
283
|
+
</Card>
|
|
249
284
|
<Card title={messages.admin.history.title} subtitle={messages.admin.history.lead}>
|
|
250
285
|
<div className={STACK_CLASS}>
|
|
251
286
|
<AccountLookup action={findAccountAction} messages={messages} locale={config.locale} />
|
package/src/schema.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Drizzle view of the module's tables (migrations/0001_create_entitlements.sql,
|
|
2
2
|
// 0002_create_payments.sql, 0003_record_payment_grants.sql, 0004_create_requests_and_grants.sql,
|
|
3
3
|
// 0005_record_refunded_amounts.sql, 0006_record_request_handover_and_prices.sql,
|
|
4
|
-
// 0007_record_failed_refunds.sql, 0008_record_request_handover_claims.sql
|
|
5
|
-
// 0009_record_pending_charge_states.sql).
|
|
4
|
+
// 0007_record_failed_refunds.sql, 0008_record_request_handover_claims.sql,
|
|
5
|
+
// 0009_record_pending_charge_states.sql and 0010_record_trial_extensions_and_index_foreign_keys.sql).
|
|
6
6
|
// The migrations are the source of truth; this file only types the queries.
|
|
7
7
|
import { bigint, boolean, integer, pgSchema, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
8
8
|
|
|
@@ -84,3 +84,12 @@ export const manualGrants = billingSchema.table("manual_grants", {
|
|
|
84
84
|
amount: bigint("amount", { mode: "number" }),
|
|
85
85
|
currency: text("currency"),
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
export const trialExtensions = billingSchema.table("trial_extensions", {
|
|
89
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
90
|
+
userId: uuid("user_id").notNull(),
|
|
91
|
+
extendedBy: uuid("extended_by"),
|
|
92
|
+
extendedAt: timestamp("extended_at", { withTimezone: true }).notNull(),
|
|
93
|
+
previousEndsAt: timestamp("previous_ends_at", { withTimezone: true }).notNull(),
|
|
94
|
+
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
|
95
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The operator's tools for turning billing on for accounts that already exist: safe ops scripts
|
|
2
2
|
// (`@softure-ai/ops/scripts`), dry run by default, `--commit` writes. `import-entitlements` records
|
|
3
3
|
// what another system knew (trial ends, paid periods, lifetime access) from a JSON file through
|
|
4
|
-
// `importEntitlement
|
|
4
|
+
// `importEntitlement`, merged (never shorter) or, with `--exact`, as given for accounts billing has
|
|
5
|
+
// no row of yet; `pin-trials` writes every derived trial into a row before a config change
|
|
5
6
|
// would move it (`pinDerivedTrials`). Reports and refusals carry counts and row numbers, never an
|
|
6
7
|
// email.
|
|
7
8
|
import { readFile } from "node:fs/promises";
|
|
@@ -17,6 +18,8 @@ import { findAccountByEmail } from "../server/plans.js";
|
|
|
17
18
|
|
|
18
19
|
export interface ImportEntitlementsScriptArgs {
|
|
19
20
|
readonly file: string;
|
|
21
|
+
/** Store each row as given (`importEntitlement`'s `replace` mode) instead of merging it. */
|
|
22
|
+
readonly exact?: true;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export type PinTrialsScriptArgs = Record<string, never>;
|
|
@@ -33,6 +36,7 @@ const MAX_LISTED = 10;
|
|
|
33
36
|
|
|
34
37
|
const importArgs = z.strictObject({
|
|
35
38
|
file: z.string().min(1, "--file=<path to a JSON file> is required"),
|
|
39
|
+
exact: z.literal(true, "--exact takes no value").optional(),
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
const pinArgs = z.strictObject({});
|
|
@@ -128,8 +132,9 @@ export function createImportEntitlementsScript(config: SoftureConfig, options: E
|
|
|
128
132
|
const clock = options.clock ?? systemClock;
|
|
129
133
|
return defineOpsScript({
|
|
130
134
|
name: "import-entitlements",
|
|
131
|
-
description:
|
|
132
|
-
|
|
135
|
+
description:
|
|
136
|
+
"Records the trial ends, paid periods and lifetime access in a JSON file for the accounts it names by email; never shortens access, except with --exact, which stores each row as given for accounts billing has no row of yet.",
|
|
137
|
+
usage: ["--file=<path to a JSON array of { email, trialEndsAt?, paidUntil?, isLifetime? }>", "--exact (store each row as given; refused for an account that already has a different row)"],
|
|
133
138
|
args: importArgs,
|
|
134
139
|
run: async (tx, args) => {
|
|
135
140
|
const ctx: BillingContext = { db: tx, clock, config };
|
|
@@ -148,13 +153,21 @@ export function createImportEntitlementsScript(config: SoftureConfig, options: E
|
|
|
148
153
|
if (unknown.length > 0) return refuseOpsScript(`${describeRows(unknown)} name no account; nothing was imported`);
|
|
149
154
|
|
|
150
155
|
const before = await summarize(ctx, userIds);
|
|
156
|
+
const mode = args.exact === true ? "replace" : "merge";
|
|
157
|
+
const existing: number[] = [];
|
|
151
158
|
for (const [index, row] of file.rows.entries()) {
|
|
152
159
|
const userId = userIds[index];
|
|
153
160
|
// One id per row: every row found an account above.
|
|
154
161
|
if (userId === undefined) throw new Error(`import-entitlements: row ${String(index + 1)} lost its account`);
|
|
155
|
-
const imported = await importEntitlement(ctx, { userId, trialEndsAt: row.trialEndsAt, paidUntil: row.paidUntil, isLifetime: row.isLifetime });
|
|
162
|
+
const imported = await importEntitlement(ctx, { userId, trialEndsAt: row.trialEndsAt, paidUntil: row.paidUntil, isLifetime: row.isLifetime, mode });
|
|
163
|
+
if (imported.ok) continue;
|
|
164
|
+
if (imported.error === "billing.entitlement_exists") existing.push(index + 1);
|
|
156
165
|
// Erased between the lookup and the change's lock.
|
|
157
|
-
|
|
166
|
+
else return refuseOpsScript(`${describeRows([index + 1])} names an account that was deleted meanwhile; nothing was imported`);
|
|
167
|
+
}
|
|
168
|
+
// The refusal rolls the whole transaction back: the rows imported before it are undone too.
|
|
169
|
+
if (existing.length > 0) {
|
|
170
|
+
return refuseOpsScript(`${describeRows(existing)} name accounts that already have a different entitlement row (--exact imports only accounts billing has no row of yet); nothing was imported`);
|
|
158
171
|
}
|
|
159
172
|
return ok({ before, after: await summarize(ctx, userIds) });
|
|
160
173
|
},
|
|
@@ -165,21 +165,52 @@ export async function changeEntitlement(
|
|
|
165
165
|
|
|
166
166
|
export interface ImportEntitlementInput {
|
|
167
167
|
readonly userId: string;
|
|
168
|
-
/** The trial end the other system knew; omitted or null keeps the account's own. */
|
|
168
|
+
/** The trial end the other system knew; omitted or null keeps the account's own (its derived trial in `replace`). */
|
|
169
169
|
readonly trialEndsAt?: Date | null;
|
|
170
170
|
/** The end of the paid period it knew; omitted or null adds none. */
|
|
171
171
|
readonly paidUntil?: Date | null;
|
|
172
172
|
/** Whether it had lifetime access. */
|
|
173
173
|
readonly isLifetime?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* `merge` (the default) moves each end only later than the account's record. `replace` stores
|
|
176
|
+
* exactly what the other system knew, a trial shorter than the derived one included, and only for
|
|
177
|
+
* an account billing has no row of yet (an adoption).
|
|
178
|
+
*/
|
|
179
|
+
readonly mode?: "merge" | "replace";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ImportEntitlementError = "billing.account_unknown" | "billing.entitlement_exists";
|
|
183
|
+
|
|
184
|
+
/** The record a `replace` import stores: the input, with the derived trial when it names none. */
|
|
185
|
+
function getReplacedRecord(derived: EntitlementRecord, input: ImportEntitlementInput): EntitlementRecord {
|
|
186
|
+
return { trialEndsAt: input.trialEndsAt ?? derived.trialEndsAt, paidUntil: input.paidUntil ?? null, isLifetime: input.isLifetime ?? false };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isSameRecord(first: EntitlementRecord, second: EntitlementRecord): boolean {
|
|
190
|
+
return (
|
|
191
|
+
first.trialEndsAt.getTime() === second.trialEndsAt.getTime() &&
|
|
192
|
+
(first.paidUntil?.getTime() ?? null) === (second.paidUntil?.getTime() ?? null) &&
|
|
193
|
+
first.isLifetime === second.isLifetime
|
|
194
|
+
);
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
/**
|
|
177
|
-
* Records what another system knew about an account (a trial end, a paid period, lifetime access)
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
198
|
+
* Records what another system knew about an account (a trial end, a paid period, lifetime access).
|
|
199
|
+
*
|
|
200
|
+
* `merge` (the default) goes through `changeEntitlement`: merged onto the account's current record,
|
|
201
|
+
* each end only moving later, so an import never takes access away and a repeat changes nothing.
|
|
202
|
+
*
|
|
203
|
+
* `replace` writes the record as given into the account's first row, so a migration is faithful: a
|
|
204
|
+
* trial the other system ended early stays ended. An account that already has a row is refused with
|
|
205
|
+
* `billing.entitlement_exists` and nothing is written, unless the row holds exactly that record (a
|
|
206
|
+
* repeat, which changes nothing).
|
|
207
|
+
*
|
|
208
|
+
* Neither is a recorded grant: it is not in the account's history. Database errors propagate.
|
|
181
209
|
*/
|
|
182
|
-
export async function importEntitlement(ctx: BillingContext, input: ImportEntitlementInput): Promise<Ok<Entitlement> | Err<"billing.account_unknown"
|
|
210
|
+
export async function importEntitlement(ctx: BillingContext, input: ImportEntitlementInput & { readonly mode?: "merge" }): Promise<Ok<Entitlement> | Err<"billing.account_unknown">>;
|
|
211
|
+
export async function importEntitlement(ctx: BillingContext, input: ImportEntitlementInput): Promise<Ok<Entitlement> | Err<ImportEntitlementError>>;
|
|
212
|
+
export async function importEntitlement(ctx: BillingContext, input: ImportEntitlementInput): Promise<Ok<Entitlement> | Err<ImportEntitlementError>> {
|
|
213
|
+
if (input.mode === "replace") return replaceEntitlement(ctx, input);
|
|
183
214
|
const changed = await changeEntitlement(ctx, input.userId, {
|
|
184
215
|
type: "import",
|
|
185
216
|
trialEndsAt: input.trialEndsAt ?? null,
|
|
@@ -192,6 +223,29 @@ export async function importEntitlement(ctx: BillingContext, input: ImportEntitl
|
|
|
192
223
|
throw new Error(`@softure-ai/billing: importing an entitlement failed with ${changed.error}`);
|
|
193
224
|
}
|
|
194
225
|
|
|
226
|
+
/** The `replace` import: the account's first row, as given. */
|
|
227
|
+
async function replaceEntitlement(ctx: BillingContext, input: ImportEntitlementInput): Promise<Ok<Entitlement> | Err<ImportEntitlementError>> {
|
|
228
|
+
if (!isUserId(input.userId)) return err("billing.account_unknown");
|
|
229
|
+
return ctx.db.transaction(async (tx) => {
|
|
230
|
+
const now = ctx.clock.now();
|
|
231
|
+
const policy = getEntitlementPolicy(ctx.config);
|
|
232
|
+
// A shared lock: the account cannot be deleted under the import, as in `changeEntitlement`.
|
|
233
|
+
const [account] = await tx.select({ createdAt: users.createdAt }).from(users).where(eq(users.id, input.userId)).for("key share");
|
|
234
|
+
if (account === undefined) return err("billing.account_unknown");
|
|
235
|
+
const record = getReplacedRecord(getDefaultRecord(ctx, account.createdAt), input);
|
|
236
|
+
const inserted = await tx
|
|
237
|
+
.insert(entitlements)
|
|
238
|
+
.values({ userId: input.userId, ...record, createdAt: now, updatedAt: now })
|
|
239
|
+
.onConflictDoNothing({ target: entitlements.userId })
|
|
240
|
+
.returning();
|
|
241
|
+
if (inserted.length > 0) return ok(resolveEntitlement(record, now, policy));
|
|
242
|
+
// The row existed, or a concurrent change inserted it (the insert waited for it).
|
|
243
|
+
const stored = await lockStoredRecord(tx, input.userId);
|
|
244
|
+
if (stored !== undefined && isSameRecord(stored, record)) return ok(resolveEntitlement(stored, now, policy));
|
|
245
|
+
return err("billing.entitlement_exists");
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
/** How many accounts `pinDerivedTrials` reads and writes at a time. */
|
|
196
250
|
const PIN_BATCH_SIZE = 500;
|
|
197
251
|
|
package/src/server/grants.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
|
9
9
|
import { and, desc, eq } from "drizzle-orm";
|
|
10
10
|
import type { AdminErrorCode, Entitlement, PaymentGrant, PlanPrice } from "../contract.js";
|
|
11
11
|
import { findPlan } from "../plans.js";
|
|
12
|
-
import { entitlements, manualGrants, paymentRequests, payments } from "../schema.js";
|
|
12
|
+
import { entitlements, manualGrants, paymentRequests, payments, trialExtensions } from "../schema.js";
|
|
13
13
|
import { findEntitlementRecord, pinEntitlementRow, type BillingContext } from "./entitlements.js";
|
|
14
14
|
import { getGrantColumns, readGrant } from "./payments.js";
|
|
15
15
|
import { applyPlan, getBillingPlans } from "./plans.js";
|
|
@@ -152,7 +152,10 @@ export async function revokeManualGrant(ctx: BillingContext, input: RevokeManual
|
|
|
152
152
|
});
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* One line of an account's history: a grant typed in by an admin, a payment a provider reported, or
|
|
157
|
+
* a trial an admin extended.
|
|
158
|
+
*/
|
|
156
159
|
export type AccountHistoryEntry =
|
|
157
160
|
| {
|
|
158
161
|
readonly source: "manual";
|
|
@@ -181,12 +184,21 @@ export type AccountHistoryEntry =
|
|
|
181
184
|
readonly refundedAt: Date | null;
|
|
182
185
|
/** The total refunded so far: part of `amount` while the payment is still paid. */
|
|
183
186
|
readonly refundedAmount: number;
|
|
187
|
+
}
|
|
188
|
+
| {
|
|
189
|
+
readonly source: "trial";
|
|
190
|
+
readonly id: string;
|
|
191
|
+
/** When it was extended. */
|
|
192
|
+
readonly at: Date;
|
|
193
|
+
/** The trial end before and after the extension (first instants no longer covered). */
|
|
194
|
+
readonly previousEndsAt: Date;
|
|
195
|
+
readonly endsAt: Date;
|
|
184
196
|
};
|
|
185
197
|
|
|
186
198
|
/** How many entries of each source the history reads. */
|
|
187
199
|
export const ACCOUNT_HISTORY_LIMIT = 100;
|
|
188
200
|
|
|
189
|
-
/** The account's manual grants and
|
|
201
|
+
/** The account's manual grants, provider payments and trial extensions, newest first. Database errors propagate. */
|
|
190
202
|
export async function getAccountHistory(ctx: Pick<BillingContext, "db">, userId: string): Promise<readonly AccountHistoryEntry[]> {
|
|
191
203
|
if (!isUserId(userId)) return [];
|
|
192
204
|
const grantRows = await ctx.db
|
|
@@ -201,6 +213,12 @@ export async function getAccountHistory(ctx: Pick<BillingContext, "db">, userId:
|
|
|
201
213
|
.where(eq(payments.userId, userId))
|
|
202
214
|
.orderBy(desc(payments.paidAt), desc(payments.id))
|
|
203
215
|
.limit(ACCOUNT_HISTORY_LIMIT);
|
|
216
|
+
const extensionRows = await ctx.db
|
|
217
|
+
.select()
|
|
218
|
+
.from(trialExtensions)
|
|
219
|
+
.where(eq(trialExtensions.userId, userId))
|
|
220
|
+
.orderBy(desc(trialExtensions.extendedAt), desc(trialExtensions.id))
|
|
221
|
+
.limit(ACCOUNT_HISTORY_LIMIT);
|
|
204
222
|
const entries: AccountHistoryEntry[] = [];
|
|
205
223
|
for (const row of grantRows) {
|
|
206
224
|
const grant = readGrant(row);
|
|
@@ -223,5 +241,8 @@ export async function getAccountHistory(ctx: Pick<BillingContext, "db">, userId:
|
|
|
223
241
|
refundedAmount: row.refundedAmount,
|
|
224
242
|
});
|
|
225
243
|
}
|
|
244
|
+
for (const row of extensionRows) {
|
|
245
|
+
entries.push({ source: "trial", id: row.id, at: row.extendedAt, previousEndsAt: row.previousEndsAt, endsAt: row.endsAt });
|
|
246
|
+
}
|
|
226
247
|
return entries.sort((first, second) => second.at.getTime() - first.at.getTime());
|
|
227
248
|
}
|
package/src/server/health.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the entitlements,
|
|
2
|
-
// payments, payment requests
|
|
2
|
+
// payments, payment requests, manual grants and trial extensions tables exist and answer, i.e. `softure migrate` ran,
|
|
3
3
|
// and the setup is sound (`adminRole` is declared), so a deploy fails before traffic. It reads no rows.
|
|
4
4
|
import { ok, type HealthCheck } from "@softure-ai/core";
|
|
5
5
|
import type { Queryable } from "@softure-ai/db";
|
|
@@ -14,5 +14,6 @@ export const checkBillingTables: HealthCheck = async (context) => {
|
|
|
14
14
|
await db.execute(sql`select 1 from billing.payments limit 0`);
|
|
15
15
|
await db.execute(sql`select 1 from billing.payment_requests limit 0`);
|
|
16
16
|
await db.execute(sql`select 1 from billing.manual_grants limit 0`);
|
|
17
|
+
await db.execute(sql`select 1 from billing.trial_extensions limit 0`);
|
|
17
18
|
return ok();
|
|
18
19
|
};
|
package/src/server/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
pinDerivedTrials,
|
|
11
11
|
type BillingContext,
|
|
12
12
|
type EntitlementEventResolver,
|
|
13
|
+
type ImportEntitlementError,
|
|
13
14
|
type ImportEntitlementInput,
|
|
14
15
|
} from "./entitlements.js";
|
|
15
16
|
export {
|
|
@@ -26,6 +27,7 @@ export {
|
|
|
26
27
|
type RevokeManualGrantInput,
|
|
27
28
|
} from "./grants.js";
|
|
28
29
|
export { checkBillingTables } from "./health.js";
|
|
30
|
+
export { extendTrialManually, type ExtendTrialManuallyInput, type TrialExtensionResult } from "./trials.js";
|
|
29
31
|
export { findAccessReminders, type AccessReminderDue, type FindAccessRemindersOptions } from "./reminders.js";
|
|
30
32
|
export {
|
|
31
33
|
failRefund,
|
|
@@ -68,6 +70,7 @@ export {
|
|
|
68
70
|
type BillingPaymentData,
|
|
69
71
|
type BillingPaymentRequestData,
|
|
70
72
|
type BillingRefundFailureData,
|
|
73
|
+
type BillingTrialExtensionData,
|
|
71
74
|
type BillingUserData,
|
|
72
75
|
} from "./privacy.js";
|
|
73
76
|
export {
|
package/src/server/privacy.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The billing part of a GDPR export and deletion (`@softure-ai/privacy`): the account's entitlement
|
|
2
2
|
// row, its provider payments and their failed refunds, its invoice requests and the plans granted
|
|
3
|
-
// to it by hand. An account
|
|
3
|
+
// to it by hand, and the trials extended for it by hand. An account
|
|
4
4
|
// without an entitlement row has no stored entitlement (its trial is derived from the account). The
|
|
5
|
-
// provider keeps its own records of the payments. Which admin granted or revoked a plan
|
|
6
|
-
// admin's data, not the account's, and is left out of the export.
|
|
5
|
+
// provider keeps its own records of the payments. Which admin granted or revoked a plan, or
|
|
6
|
+
// extended a trial, is the admin's data, not the account's, and is left out of the export.
|
|
7
7
|
import { users } from "@softure-ai/auth";
|
|
8
8
|
import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
|
|
9
9
|
import type { Queryable } from "@softure-ai/db";
|
|
10
10
|
import { asc, eq } from "drizzle-orm";
|
|
11
|
-
import { entitlements, manualGrants, paymentRequests, payments, refundFailures } from "../schema.js";
|
|
11
|
+
import { entitlements, manualGrants, paymentRequests, payments, refundFailures, trialExtensions } from "../schema.js";
|
|
12
12
|
import { isUserId } from "./user-id.js";
|
|
13
13
|
|
|
14
14
|
/** One provider payment, as it appears in an export. */
|
|
@@ -69,6 +69,14 @@ export interface BillingManualGrantData {
|
|
|
69
69
|
readonly currency: string | null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** One trial extended by hand, as it appears in an export. */
|
|
73
|
+
export interface BillingTrialExtensionData {
|
|
74
|
+
readonly extendedAt: Date;
|
|
75
|
+
/** The trial end before and after the extension (first instants no longer covered). */
|
|
76
|
+
readonly previousEndsAt: Date;
|
|
77
|
+
readonly endsAt: Date;
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
/** What billing holds about one user, as it appears in their export. */
|
|
73
81
|
export interface BillingUserData {
|
|
74
82
|
readonly entitlement: {
|
|
@@ -86,9 +94,11 @@ export interface BillingUserData {
|
|
|
86
94
|
readonly paymentRequests: readonly BillingPaymentRequestData[];
|
|
87
95
|
/** Oldest first. */
|
|
88
96
|
readonly manualGrants: readonly BillingManualGrantData[];
|
|
97
|
+
/** Oldest first. */
|
|
98
|
+
readonly trialExtensions: readonly BillingTrialExtensionData[];
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
const EMPTY_USER_DATA: BillingUserData = { entitlement: null, payments: [], refundFailures: [], paymentRequests: [], manualGrants: [] };
|
|
101
|
+
const EMPTY_USER_DATA: BillingUserData = { entitlement: null, payments: [], refundFailures: [], paymentRequests: [], manualGrants: [], trialExtensions: [] };
|
|
92
102
|
|
|
93
103
|
export async function exportBillingUserData(context: ModuleContext, userId: string): Promise<Ok<BillingUserData>> {
|
|
94
104
|
if (!isUserId(userId)) return ok(EMPTY_USER_DATA);
|
|
@@ -165,7 +175,19 @@ export async function exportBillingUserData(context: ModuleContext, userId: stri
|
|
|
165
175
|
.from(manualGrants)
|
|
166
176
|
.where(eq(manualGrants.userId, userId))
|
|
167
177
|
.orderBy(asc(manualGrants.grantedAt), asc(manualGrants.id));
|
|
168
|
-
|
|
178
|
+
const extensionRows = await db
|
|
179
|
+
.select({ extendedAt: trialExtensions.extendedAt, previousEndsAt: trialExtensions.previousEndsAt, endsAt: trialExtensions.endsAt })
|
|
180
|
+
.from(trialExtensions)
|
|
181
|
+
.where(eq(trialExtensions.userId, userId))
|
|
182
|
+
.orderBy(asc(trialExtensions.extendedAt), asc(trialExtensions.id));
|
|
183
|
+
return ok({
|
|
184
|
+
entitlement: row ?? null,
|
|
185
|
+
payments: paymentRows,
|
|
186
|
+
refundFailures: failureRows,
|
|
187
|
+
paymentRequests: requestRows,
|
|
188
|
+
manualGrants: grantRows,
|
|
189
|
+
trialExtensions: extensionRows,
|
|
190
|
+
});
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
export async function deleteBillingUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
|
|
@@ -180,6 +202,7 @@ export async function deleteBillingUserData(context: ModuleContext, userId: stri
|
|
|
180
202
|
// Grants first: they reference the requests they answered.
|
|
181
203
|
await db.delete(manualGrants).where(eq(manualGrants.userId, userId));
|
|
182
204
|
await db.delete(paymentRequests).where(eq(paymentRequests.userId, userId));
|
|
205
|
+
await db.delete(trialExtensions).where(eq(trialExtensions.userId, userId));
|
|
183
206
|
return ok();
|
|
184
207
|
}
|
|
185
208
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Trials an admin extends by hand (the admin page's "Extend a trial" form): the trial end moves
|
|
2
|
+
// later and the change is recorded in `billing.trial_extensions` with the end before and after, so
|
|
3
|
+
// the account's history lists it beside the manual grants. Unlike a granted plan it never writes
|
|
4
|
+
// `paid_until`, so an app that counts paying accounts by it does not count a free extension. Locks
|
|
5
|
+
// follow every change's order (account, then the entitlement row, then the new row).
|
|
6
|
+
import { users } from "@softure-ai/auth";
|
|
7
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import type { Entitlement, TrialExtensionErrorCode } from "../contract.js";
|
|
10
|
+
import { applyEntitlementEvent, resolveEntitlement } from "../entitlement.js";
|
|
11
|
+
import { entitlements, trialExtensions } from "../schema.js";
|
|
12
|
+
import { findEntitlementRecord, pinEntitlementRow, type BillingContext } from "./entitlements.js";
|
|
13
|
+
import { getEntitlementPolicy } from "./options.js";
|
|
14
|
+
import { lockEntitlementRow } from "./take-back.js";
|
|
15
|
+
import { isUserId } from "./user-id.js";
|
|
16
|
+
|
|
17
|
+
export interface ExtendTrialManuallyInput {
|
|
18
|
+
readonly userId: string;
|
|
19
|
+
/** The trial's new end: the first instant it no longer covers. */
|
|
20
|
+
readonly until: Date;
|
|
21
|
+
/** The admin who extends it; null for an extension without one (a script). */
|
|
22
|
+
readonly adminId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TrialExtensionResult {
|
|
26
|
+
readonly extensionId: string;
|
|
27
|
+
/** Where the account stands after the extension (paid access still wins over the trial). */
|
|
28
|
+
readonly entitlement: Entitlement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Moves the account's trial end to `until` and records it, in one transaction. Refuses an end at
|
|
33
|
+
* or before now (`billing.end_not_in_future`) and one at or before the current trial end
|
|
34
|
+
* (`billing.trial_not_extended`); every refusal writes nothing. Database errors propagate.
|
|
35
|
+
*/
|
|
36
|
+
export async function extendTrialManually(ctx: BillingContext, input: ExtendTrialManuallyInput): Promise<Ok<TrialExtensionResult> | Err<TrialExtensionErrorCode>> {
|
|
37
|
+
if (!isUserId(input.userId)) return err("billing.account_unknown");
|
|
38
|
+
return ctx.db.transaction(async (tx) => {
|
|
39
|
+
const now = ctx.clock.now();
|
|
40
|
+
// A shared lock: the account cannot be deleted before the extension below.
|
|
41
|
+
const [account] = await tx.select({ createdAt: users.createdAt }).from(users).where(eq(users.id, input.userId)).for("key share");
|
|
42
|
+
if (account === undefined) return err("billing.account_unknown");
|
|
43
|
+
// The derived trial pinned and locked before the check, so a concurrent change is seen here.
|
|
44
|
+
const isPinned = await pinEntitlementRow({ ...ctx, db: tx }, { userId: input.userId, accountCreatedAt: account.createdAt });
|
|
45
|
+
await lockEntitlementRow(tx, input.userId);
|
|
46
|
+
const record = await findEntitlementRecord({ ...ctx, db: tx }, input.userId);
|
|
47
|
+
// The account was locked above and its row pinned.
|
|
48
|
+
if (record === null) throw new Error("@softure-ai/billing: an account vanished while its trial was being extended");
|
|
49
|
+
|
|
50
|
+
const refusal = input.until <= now ? "billing.end_not_in_future" : input.until <= record.trialEndsAt ? "billing.trial_not_extended" : null;
|
|
51
|
+
if (refusal !== null) {
|
|
52
|
+
// The pin is this transaction's own row, nobody else has seen it: undo it, so the refusal writes nothing.
|
|
53
|
+
if (isPinned) await tx.delete(entitlements).where(eq(entitlements.userId, input.userId));
|
|
54
|
+
return err(refusal);
|
|
55
|
+
}
|
|
56
|
+
const next = applyEntitlementEvent(record, { type: "extend_trial", until: input.until }, now);
|
|
57
|
+
// Both of the event's refusals were checked above.
|
|
58
|
+
if (!next.ok) throw new Error(`@softure-ai/billing: extending a trial failed with ${next.error}`);
|
|
59
|
+
await tx
|
|
60
|
+
.update(entitlements)
|
|
61
|
+
.set({ trialEndsAt: next.value.trialEndsAt, updatedAt: now })
|
|
62
|
+
.where(eq(entitlements.userId, input.userId));
|
|
63
|
+
const [row] = await tx
|
|
64
|
+
.insert(trialExtensions)
|
|
65
|
+
.values({ userId: input.userId, extendedBy: input.adminId, extendedAt: now, previousEndsAt: record.trialEndsAt, endsAt: next.value.trialEndsAt })
|
|
66
|
+
.returning();
|
|
67
|
+
if (row === undefined) throw new Error("@softure-ai/billing: recording a trial extension returned no row");
|
|
68
|
+
return ok({ extensionId: row.id, entitlement: resolveEntitlement(next.value, now, getEntitlementPolicy(ctx.config)) });
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -23,3 +23,4 @@ export {
|
|
|
23
23
|
type PaymentRequestRow,
|
|
24
24
|
} from "./payment-requests.js";
|
|
25
25
|
export { PricingTiles, type PricingTilesProps, type PricingTilesSlot } from "./pricing-tiles.js";
|
|
26
|
+
export { TrialForm, type TrialFormAction, type TrialFormProps, type TrialFormSlot } from "./trial-form.js";
|