@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +17 -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/messages/en.d.ts +17 -0
  16. package/dist/messages/en.d.ts.map +1 -1
  17. package/dist/messages/en.js +17 -0
  18. package/dist/messages/en.js.map +1 -1
  19. package/dist/messages/index.d.ts +36 -2
  20. package/dist/messages/index.d.ts.map +1 -1
  21. package/dist/messages/index.js.map +1 -1
  22. package/dist/messages/pl.d.ts.map +1 -1
  23. package/dist/messages/pl.js +17 -0
  24. package/dist/messages/pl.js.map +1 -1
  25. package/dist/next/actions.d.ts +8 -1
  26. package/dist/next/actions.d.ts.map +1 -1
  27. package/dist/next/actions.js +37 -1
  28. package/dist/next/actions.js.map +1 -1
  29. package/dist/next/index.d.ts +1 -1
  30. package/dist/next/index.d.ts.map +1 -1
  31. package/dist/next/index.js +1 -1
  32. package/dist/next/index.js.map +1 -1
  33. package/dist/next/pages.d.ts +13 -3
  34. package/dist/next/pages.d.ts.map +1 -1
  35. package/dist/next/pages.js +25 -8
  36. package/dist/next/pages.js.map +1 -1
  37. package/dist/schema.d.ts +109 -0
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +10 -2
  40. package/dist/schema.js.map +1 -1
  41. package/dist/scripts/entitlement-scripts.d.ts +2 -0
  42. package/dist/scripts/entitlement-scripts.d.ts.map +1 -1
  43. package/dist/scripts/entitlement-scripts.js +17 -5
  44. package/dist/scripts/entitlement-scripts.js.map +1 -1
  45. package/dist/server/entitlements.d.ts +23 -6
  46. package/dist/server/entitlements.d.ts.map +1 -1
  47. package/dist/server/entitlements.js +37 -6
  48. package/dist/server/entitlements.js.map +1 -1
  49. package/dist/server/grants.d.ts +13 -2
  50. package/dist/server/grants.d.ts.map +1 -1
  51. package/dist/server/grants.js +11 -2
  52. package/dist/server/grants.js.map +1 -1
  53. package/dist/server/health.d.ts.map +1 -1
  54. package/dist/server/health.js +2 -1
  55. package/dist/server/health.js.map +1 -1
  56. package/dist/server/index.d.ts +3 -2
  57. package/dist/server/index.d.ts.map +1 -1
  58. package/dist/server/index.js +1 -0
  59. package/dist/server/index.js.map +1 -1
  60. package/dist/server/privacy.d.ts +9 -0
  61. package/dist/server/privacy.d.ts.map +1 -1
  62. package/dist/server/privacy.js +19 -6
  63. package/dist/server/privacy.js.map +1 -1
  64. package/dist/server/trials.d.ts +22 -0
  65. package/dist/server/trials.d.ts.map +1 -0
  66. package/dist/server/trials.js +60 -0
  67. package/dist/server/trials.js.map +1 -0
  68. package/dist/ui/index.d.ts +1 -0
  69. package/dist/ui/index.d.ts.map +1 -1
  70. package/dist/ui/index.js +1 -0
  71. package/dist/ui/index.js.map +1 -1
  72. package/dist/ui/trial-form.d.ts +16 -0
  73. package/dist/ui/trial-form.d.ts.map +1 -0
  74. package/dist/ui/trial-form.js +25 -0
  75. package/dist/ui/trial-form.js.map +1 -0
  76. package/migrations/0010_record_trial_extensions_and_index_foreign_keys.sql +27 -0
  77. package/module.json +2 -2
  78. package/package.json +1 -1
  79. package/src/contract.ts +28 -0
  80. package/src/fields.ts +2 -0
  81. package/src/index.ts +7 -3
  82. package/src/messages/en.ts +17 -0
  83. package/src/messages/index.ts +2 -2
  84. package/src/messages/pl.ts +17 -0
  85. package/src/next/actions.ts +35 -2
  86. package/src/next/index.ts +9 -1
  87. package/src/next/pages.tsx +41 -6
  88. package/src/schema.ts +11 -2
  89. package/src/scripts/entitlement-scripts.ts +18 -5
  90. package/src/server/entitlements.ts +60 -6
  91. package/src/server/grants.ts +24 -3
  92. package/src/server/health.ts +2 -1
  93. package/src/server/index.ts +3 -0
  94. package/src/server/privacy.ts +29 -6
  95. package/src/server/trials.ts +70 -0
  96. package/src/ui/index.ts +1 -0
  97. package/src/ui/trial-form.tsx +80 -0
@@ -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 {
@@ -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 is the
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
- return ok({ entitlement: row ?? null, payments: paymentRows, refundFailures: failureRows, paymentRequests: requestRows, manualGrants: grantRows });
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";