@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +75 -16
  3. package/dist/contract.d.ts +19 -0
  4. package/dist/contract.d.ts.map +1 -1
  5. package/dist/contract.js +1 -0
  6. package/dist/contract.js.map +1 -1
  7. package/dist/fields.d.ts +2 -0
  8. package/dist/fields.d.ts.map +1 -1
  9. package/dist/fields.js +2 -0
  10. package/dist/fields.js.map +1 -1
  11. package/dist/index.d.ts +19 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +4 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/mailing/reminder-mail.d.ts +8 -2
  16. package/dist/mailing/reminder-mail.d.ts.map +1 -1
  17. package/dist/mailing/reminder-mail.js +6 -1
  18. package/dist/mailing/reminder-mail.js.map +1 -1
  19. package/dist/messages/en.d.ts +17 -0
  20. package/dist/messages/en.d.ts.map +1 -1
  21. package/dist/messages/en.js +17 -0
  22. package/dist/messages/en.js.map +1 -1
  23. package/dist/messages/index.d.ts +36 -2
  24. package/dist/messages/index.d.ts.map +1 -1
  25. package/dist/messages/index.js.map +1 -1
  26. package/dist/messages/pl.d.ts.map +1 -1
  27. package/dist/messages/pl.js +17 -0
  28. package/dist/messages/pl.js.map +1 -1
  29. package/dist/next/actions.d.ts +8 -1
  30. package/dist/next/actions.d.ts.map +1 -1
  31. package/dist/next/actions.js +37 -1
  32. package/dist/next/actions.js.map +1 -1
  33. package/dist/next/index.d.ts +1 -1
  34. package/dist/next/index.d.ts.map +1 -1
  35. package/dist/next/index.js +1 -1
  36. package/dist/next/index.js.map +1 -1
  37. package/dist/next/pages.d.ts +13 -3
  38. package/dist/next/pages.d.ts.map +1 -1
  39. package/dist/next/pages.js +25 -8
  40. package/dist/next/pages.js.map +1 -1
  41. package/dist/schema.d.ts +109 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +10 -2
  44. package/dist/schema.js.map +1 -1
  45. package/dist/scripts/entitlement-scripts.d.ts +2 -0
  46. package/dist/scripts/entitlement-scripts.d.ts.map +1 -1
  47. package/dist/scripts/entitlement-scripts.js +17 -5
  48. package/dist/scripts/entitlement-scripts.js.map +1 -1
  49. package/dist/server/entitlements.d.ts +23 -6
  50. package/dist/server/entitlements.d.ts.map +1 -1
  51. package/dist/server/entitlements.js +37 -6
  52. package/dist/server/entitlements.js.map +1 -1
  53. package/dist/server/grants.d.ts +13 -2
  54. package/dist/server/grants.d.ts.map +1 -1
  55. package/dist/server/grants.js +11 -2
  56. package/dist/server/grants.js.map +1 -1
  57. package/dist/server/health.d.ts.map +1 -1
  58. package/dist/server/health.js +2 -1
  59. package/dist/server/health.js.map +1 -1
  60. package/dist/server/index.d.ts +3 -2
  61. package/dist/server/index.d.ts.map +1 -1
  62. package/dist/server/index.js +1 -0
  63. package/dist/server/index.js.map +1 -1
  64. package/dist/server/privacy.d.ts +9 -0
  65. package/dist/server/privacy.d.ts.map +1 -1
  66. package/dist/server/privacy.js +19 -6
  67. package/dist/server/privacy.js.map +1 -1
  68. package/dist/server/trials.d.ts +22 -0
  69. package/dist/server/trials.d.ts.map +1 -0
  70. package/dist/server/trials.js +60 -0
  71. package/dist/server/trials.js.map +1 -0
  72. package/dist/ui/index.d.ts +1 -0
  73. package/dist/ui/index.d.ts.map +1 -1
  74. package/dist/ui/index.js +1 -0
  75. package/dist/ui/index.js.map +1 -1
  76. package/dist/ui/trial-form.d.ts +16 -0
  77. package/dist/ui/trial-form.d.ts.map +1 -0
  78. package/dist/ui/trial-form.js +25 -0
  79. package/dist/ui/trial-form.js.map +1 -0
  80. package/migrations/0010_record_trial_extensions_and_index_foreign_keys.sql +27 -0
  81. package/module.json +2 -2
  82. package/package.json +1 -1
  83. package/src/contract.ts +28 -0
  84. package/src/fields.ts +2 -0
  85. package/src/index.ts +7 -3
  86. package/src/mailing/reminder-mail.ts +14 -3
  87. package/src/messages/en.ts +17 -0
  88. package/src/messages/index.ts +2 -2
  89. package/src/messages/pl.ts +17 -0
  90. package/src/next/actions.ts +35 -2
  91. package/src/next/index.ts +9 -1
  92. package/src/next/pages.tsx +41 -6
  93. package/src/schema.ts +11 -2
  94. package/src/scripts/entitlement-scripts.ts +18 -5
  95. package/src/server/entitlements.ts +60 -6
  96. package/src/server/grants.ts +24 -3
  97. package/src/server/health.ts +2 -1
  98. package/src/server/index.ts +3 -0
  99. package/src/server/privacy.ts +29 -6
  100. package/src/server/trials.ts +70 -0
  101. package/src/ui/index.ts +1 -0
  102. package/src/ui/trial-form.tsx +80 -0
@@ -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.",
@@ -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);
@@ -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.",
@@ -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 type { AdminActionErrorCode, AdminActionState, GrantFormState, PaymentFormState } from "../contract.js";
15
- import { ACCOUNT_PARAM, EMAIL_FIELD, GRANT_FIELD, INVOICE_FIELDS, PLAN_FIELD, REQUEST_FIELD } from "../fields.js";
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 { dismissRequestAction, findAccountAction, grantPlanAction, grantRequestAction, revokeGrantAction, startPaymentAction } from "./actions.js";
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";
@@ -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 and
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`; `pin-trials` writes every derived trial into a row before a config change
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: "Records the trial ends, paid periods and lifetime access in a JSON file for the accounts it names by email; never shortens access.",
132
- usage: ["--file=<path to a JSON array of { email, trialEndsAt?, paidUntil?, isLifetime? }>"],
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
- if (!imported.ok) return refuseOpsScript(`${describeRows([index + 1])} names an account that was deleted meanwhile; nothing was imported`);
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
- * through `changeEntitlement`: merged onto the account's current record, each end only moving
179
- * later, so an import never takes access away and a repeat changes nothing. Not a recorded grant:
180
- * it is not in the account's history. Database errors propagate.
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
 
@@ -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
- /** One line of an account's history: a grant typed in by an admin, or a payment a provider reported. */
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 provider payments, newest first. Database errors propagate. */
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
  }
@@ -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 and manual grants tables exist and answer, i.e. `softure migrate` ran,
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
  };
@@ -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 {