@softure-ai/billing 0.1.6 → 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 +22 -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/mailing/reminder-mail.d.ts +8 -2
- package/dist/mailing/reminder-mail.d.ts.map +1 -1
- package/dist/mailing/reminder-mail.js +6 -1
- package/dist/mailing/reminder-mail.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/mailing/reminder-mail.ts +14 -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/messages/en.ts
CHANGED
|
@@ -46,6 +46,7 @@ export const en = {
|
|
|
46
46
|
empty: "No plans are available yet.",
|
|
47
47
|
},
|
|
48
48
|
payment: {
|
|
49
|
+
heading: "Payment",
|
|
49
50
|
title: "Choose a plan",
|
|
50
51
|
lead: "Pick the plan that suits you. Paid access starts when your current access ends.",
|
|
51
52
|
plansLabel: "Plans",
|
|
@@ -67,6 +68,7 @@ export const en = {
|
|
|
67
68
|
lifetime: "You have lifetime access: there is nothing left to pay for.",
|
|
68
69
|
},
|
|
69
70
|
admin: {
|
|
71
|
+
heading: "Billing",
|
|
70
72
|
title: "Grant access",
|
|
71
73
|
lead: "Grant a plan to an account once its payment has arrived. A paid period starts when the account's current access ends.",
|
|
72
74
|
email: "Account email",
|
|
@@ -76,6 +78,15 @@ export const en = {
|
|
|
76
78
|
granted: "{email} now has {plan}.",
|
|
77
79
|
grantedUntil: "{email} now has {plan}, with access until {date}.",
|
|
78
80
|
noPlans: "There are no plans to grant: add them to billing({ plans }).",
|
|
81
|
+
trial: {
|
|
82
|
+
title: "Extend a trial",
|
|
83
|
+
lead: "Give an account a longer trial for free, e.g. an invited user. It is not paid access: the account keeps its paid periods, and the trial lasts through the day you choose.",
|
|
84
|
+
email: "Email of the account to extend",
|
|
85
|
+
lastDay: "Last day of the trial",
|
|
86
|
+
submit: "Extend trial",
|
|
87
|
+
pending: "Extending…",
|
|
88
|
+
extended: "{email} now has a trial until {date}.",
|
|
89
|
+
},
|
|
79
90
|
requests: {
|
|
80
91
|
title: "Invoice requests",
|
|
81
92
|
lead: "Requests sent from the payment page. Grant the plan once the invoice is paid, or dismiss the request.",
|
|
@@ -119,6 +130,10 @@ export const en = {
|
|
|
119
130
|
revoke: "Revoke",
|
|
120
131
|
revoking: "Revoking…",
|
|
121
132
|
revokeLabel: "Revoke {plan} granted on {date}",
|
|
133
|
+
trialExtended: "Trial extended",
|
|
134
|
+
trialUntil: "Trial until {date}",
|
|
135
|
+
extendedOn: "Extended on {date}",
|
|
136
|
+
trialWasUntil: "The trial was until {date}",
|
|
122
137
|
},
|
|
123
138
|
},
|
|
124
139
|
errors: {
|
|
@@ -135,6 +150,8 @@ export const en = {
|
|
|
135
150
|
lifetime_active: "This account already has lifetime access: there is nothing to pay for or grant.",
|
|
136
151
|
request_closed: "This request was granted or dismissed already.",
|
|
137
152
|
grant_revoked: "This grant was revoked already.",
|
|
153
|
+
trial_not_extended: "The trial already lasts through this day or longer.",
|
|
154
|
+
day_invalid: "Enter a date, e.g. 2026-11-30.",
|
|
138
155
|
},
|
|
139
156
|
security: {
|
|
140
157
|
rate_limited: "Too many attempts. Try again later.",
|
package/src/messages/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AdminActionErrorCode, BillingFormErrorCode, GrantFormErrorCode, PaymentFormErrorCode } from "../contract.js";
|
|
1
|
+
import type { AdminActionErrorCode, BillingFormErrorCode, GrantFormErrorCode, PaymentFormErrorCode, TrialFormErrorCode } from "../contract.js";
|
|
2
2
|
import { en } from "./en.js";
|
|
3
3
|
import { pl } from "./pl.js";
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@ export type BillingMessages = typeof en;
|
|
|
8
8
|
export const billingMessages = { en, pl };
|
|
9
9
|
|
|
10
10
|
/** The copy for an error code; an unknown code gets the generic failure. */
|
|
11
|
-
export function getBillingErrorMessage(messages: BillingMessages, code: BillingFormErrorCode | PaymentFormErrorCode | GrantFormErrorCode | AdminActionErrorCode): string {
|
|
11
|
+
export function getBillingErrorMessage(messages: BillingMessages, code: BillingFormErrorCode | PaymentFormErrorCode | GrantFormErrorCode | TrialFormErrorCode | AdminActionErrorCode): string {
|
|
12
12
|
const separator = code.lastIndexOf(".");
|
|
13
13
|
const namespace = code.slice(0, separator);
|
|
14
14
|
const name = code.slice(separator + 1);
|
package/src/messages/pl.ts
CHANGED
|
@@ -48,6 +48,7 @@ export const pl: typeof en = {
|
|
|
48
48
|
empty: "Nie ma jeszcze dostępnych planów.",
|
|
49
49
|
},
|
|
50
50
|
payment: {
|
|
51
|
+
heading: "Płatność",
|
|
51
52
|
title: "Wybierz plan",
|
|
52
53
|
lead: "Wybierz plan dla siebie. Płatny dostęp zaczyna się, gdy skończy się obecny.",
|
|
53
54
|
plansLabel: "Plany",
|
|
@@ -69,6 +70,7 @@ export const pl: typeof en = {
|
|
|
69
70
|
lifetime: "Masz dostęp dożywotni: nie ma już za co płacić.",
|
|
70
71
|
},
|
|
71
72
|
admin: {
|
|
73
|
+
heading: "Rozliczenia",
|
|
72
74
|
title: "Nadaj dostęp",
|
|
73
75
|
lead: "Nadaj plan kontu, gdy wpłynie płatność. Płatny okres zaczyna się, gdy skończy się obecny dostęp konta.",
|
|
74
76
|
email: "E-mail konta",
|
|
@@ -78,6 +80,15 @@ export const pl: typeof en = {
|
|
|
78
80
|
granted: "{email} ma teraz {plan}.",
|
|
79
81
|
grantedUntil: "{email} ma teraz {plan}, z dostępem do {date}.",
|
|
80
82
|
noPlans: "Nie ma planów do nadania: dodaj je w billing({ plans }).",
|
|
83
|
+
trial: {
|
|
84
|
+
title: "Przedłuż okres próbny",
|
|
85
|
+
lead: "Daj kontu dłuższy okres próbny za darmo, np. zaproszonej osobie. To nie jest płatny dostęp: konto zachowuje opłacone okresy, a okres próbny trwa do końca wybranego dnia.",
|
|
86
|
+
email: "E-mail konta do przedłużenia",
|
|
87
|
+
lastDay: "Ostatni dzień okresu próbnego",
|
|
88
|
+
submit: "Przedłuż okres próbny",
|
|
89
|
+
pending: "Przedłużanie…",
|
|
90
|
+
extended: "{email} ma teraz okres próbny do {date}.",
|
|
91
|
+
},
|
|
81
92
|
requests: {
|
|
82
93
|
title: "Prośby o fakturę",
|
|
83
94
|
lead: "Prośby wysłane ze strony płatności. Nadaj plan, gdy faktura zostanie opłacona, albo odrzuć prośbę.",
|
|
@@ -121,6 +132,10 @@ export const pl: typeof en = {
|
|
|
121
132
|
revoke: "Cofnij",
|
|
122
133
|
revoking: "Cofanie…",
|
|
123
134
|
revokeLabel: "Cofnij {plan} nadany {date}",
|
|
135
|
+
trialExtended: "Przedłużony okres próbny",
|
|
136
|
+
trialUntil: "Okres próbny do {date}",
|
|
137
|
+
extendedOn: "Przedłużono {date}",
|
|
138
|
+
trialWasUntil: "Wcześniej okres próbny do {date}",
|
|
124
139
|
},
|
|
125
140
|
},
|
|
126
141
|
errors: {
|
|
@@ -137,6 +152,8 @@ export const pl: typeof en = {
|
|
|
137
152
|
lifetime_active: "To konto ma już dostęp dożywotni: nie ma za co płacić ani czego nadawać.",
|
|
138
153
|
request_closed: "Ta prośba została już obsłużona lub odrzucona.",
|
|
139
154
|
grant_revoked: "Ten dostęp został już cofnięty.",
|
|
155
|
+
trial_not_extended: "Okres próbny trwa już do tego dnia albo dłużej.",
|
|
156
|
+
day_invalid: "Wpisz datę, np. 2026-11-30.",
|
|
140
157
|
},
|
|
141
158
|
security: {
|
|
142
159
|
rate_limited: "Zbyt wiele prób. Spróbuj później.",
|
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 {
|