@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/server/privacy.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The billing part of a GDPR export and deletion (`@softure-ai/privacy`): the account's entitlement
|
|
2
2
|
// row, its provider payments and their failed refunds, its invoice requests and the plans granted
|
|
3
|
-
// to it by hand. An account
|
|
3
|
+
// to it by hand, and the trials extended for it by hand. An account
|
|
4
4
|
// without an entitlement row has no stored entitlement (its trial is derived from the account). The
|
|
5
|
-
// provider keeps its own records of the payments. Which admin granted or revoked a plan
|
|
6
|
-
// admin's data, not the account's, and is left out of the export.
|
|
5
|
+
// provider keeps its own records of the payments. Which admin granted or revoked a plan, or
|
|
6
|
+
// extended a trial, is the admin's data, not the account's, and is left out of the export.
|
|
7
7
|
import { users } from "@softure-ai/auth";
|
|
8
8
|
import { ok, type ModuleContext, type Ok, type PrivacyContributor } from "@softure-ai/core";
|
|
9
9
|
import type { Queryable } from "@softure-ai/db";
|
|
10
10
|
import { asc, eq } from "drizzle-orm";
|
|
11
|
-
import { entitlements, manualGrants, paymentRequests, payments, refundFailures } from "../schema.js";
|
|
11
|
+
import { entitlements, manualGrants, paymentRequests, payments, refundFailures, trialExtensions } from "../schema.js";
|
|
12
12
|
import { isUserId } from "./user-id.js";
|
|
13
13
|
|
|
14
14
|
/** One provider payment, as it appears in an export. */
|
|
@@ -69,6 +69,14 @@ export interface BillingManualGrantData {
|
|
|
69
69
|
readonly currency: string | null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** One trial extended by hand, as it appears in an export. */
|
|
73
|
+
export interface BillingTrialExtensionData {
|
|
74
|
+
readonly extendedAt: Date;
|
|
75
|
+
/** The trial end before and after the extension (first instants no longer covered). */
|
|
76
|
+
readonly previousEndsAt: Date;
|
|
77
|
+
readonly endsAt: Date;
|
|
78
|
+
}
|
|
79
|
+
|
|
72
80
|
/** What billing holds about one user, as it appears in their export. */
|
|
73
81
|
export interface BillingUserData {
|
|
74
82
|
readonly entitlement: {
|
|
@@ -86,9 +94,11 @@ export interface BillingUserData {
|
|
|
86
94
|
readonly paymentRequests: readonly BillingPaymentRequestData[];
|
|
87
95
|
/** Oldest first. */
|
|
88
96
|
readonly manualGrants: readonly BillingManualGrantData[];
|
|
97
|
+
/** Oldest first. */
|
|
98
|
+
readonly trialExtensions: readonly BillingTrialExtensionData[];
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
const EMPTY_USER_DATA: BillingUserData = { entitlement: null, payments: [], refundFailures: [], paymentRequests: [], manualGrants: [] };
|
|
101
|
+
const EMPTY_USER_DATA: BillingUserData = { entitlement: null, payments: [], refundFailures: [], paymentRequests: [], manualGrants: [], trialExtensions: [] };
|
|
92
102
|
|
|
93
103
|
export async function exportBillingUserData(context: ModuleContext, userId: string): Promise<Ok<BillingUserData>> {
|
|
94
104
|
if (!isUserId(userId)) return ok(EMPTY_USER_DATA);
|
|
@@ -165,7 +175,19 @@ export async function exportBillingUserData(context: ModuleContext, userId: stri
|
|
|
165
175
|
.from(manualGrants)
|
|
166
176
|
.where(eq(manualGrants.userId, userId))
|
|
167
177
|
.orderBy(asc(manualGrants.grantedAt), asc(manualGrants.id));
|
|
168
|
-
|
|
178
|
+
const extensionRows = await db
|
|
179
|
+
.select({ extendedAt: trialExtensions.extendedAt, previousEndsAt: trialExtensions.previousEndsAt, endsAt: trialExtensions.endsAt })
|
|
180
|
+
.from(trialExtensions)
|
|
181
|
+
.where(eq(trialExtensions.userId, userId))
|
|
182
|
+
.orderBy(asc(trialExtensions.extendedAt), asc(trialExtensions.id));
|
|
183
|
+
return ok({
|
|
184
|
+
entitlement: row ?? null,
|
|
185
|
+
payments: paymentRows,
|
|
186
|
+
refundFailures: failureRows,
|
|
187
|
+
paymentRequests: requestRows,
|
|
188
|
+
manualGrants: grantRows,
|
|
189
|
+
trialExtensions: extensionRows,
|
|
190
|
+
});
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
export async function deleteBillingUserData(context: ModuleContext, userId: string): Promise<Ok<undefined>> {
|
|
@@ -180,6 +202,7 @@ export async function deleteBillingUserData(context: ModuleContext, userId: stri
|
|
|
180
202
|
// Grants first: they reference the requests they answered.
|
|
181
203
|
await db.delete(manualGrants).where(eq(manualGrants.userId, userId));
|
|
182
204
|
await db.delete(paymentRequests).where(eq(paymentRequests.userId, userId));
|
|
205
|
+
await db.delete(trialExtensions).where(eq(trialExtensions.userId, userId));
|
|
183
206
|
return ok();
|
|
184
207
|
}
|
|
185
208
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Trials an admin extends by hand (the admin page's "Extend a trial" form): the trial end moves
|
|
2
|
+
// later and the change is recorded in `billing.trial_extensions` with the end before and after, so
|
|
3
|
+
// the account's history lists it beside the manual grants. Unlike a granted plan it never writes
|
|
4
|
+
// `paid_until`, so an app that counts paying accounts by it does not count a free extension. Locks
|
|
5
|
+
// follow every change's order (account, then the entitlement row, then the new row).
|
|
6
|
+
import { users } from "@softure-ai/auth";
|
|
7
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import type { Entitlement, TrialExtensionErrorCode } from "../contract.js";
|
|
10
|
+
import { applyEntitlementEvent, resolveEntitlement } from "../entitlement.js";
|
|
11
|
+
import { entitlements, trialExtensions } from "../schema.js";
|
|
12
|
+
import { findEntitlementRecord, pinEntitlementRow, type BillingContext } from "./entitlements.js";
|
|
13
|
+
import { getEntitlementPolicy } from "./options.js";
|
|
14
|
+
import { lockEntitlementRow } from "./take-back.js";
|
|
15
|
+
import { isUserId } from "./user-id.js";
|
|
16
|
+
|
|
17
|
+
export interface ExtendTrialManuallyInput {
|
|
18
|
+
readonly userId: string;
|
|
19
|
+
/** The trial's new end: the first instant it no longer covers. */
|
|
20
|
+
readonly until: Date;
|
|
21
|
+
/** The admin who extends it; null for an extension without one (a script). */
|
|
22
|
+
readonly adminId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TrialExtensionResult {
|
|
26
|
+
readonly extensionId: string;
|
|
27
|
+
/** Where the account stands after the extension (paid access still wins over the trial). */
|
|
28
|
+
readonly entitlement: Entitlement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Moves the account's trial end to `until` and records it, in one transaction. Refuses an end at
|
|
33
|
+
* or before now (`billing.end_not_in_future`) and one at or before the current trial end
|
|
34
|
+
* (`billing.trial_not_extended`); every refusal writes nothing. Database errors propagate.
|
|
35
|
+
*/
|
|
36
|
+
export async function extendTrialManually(ctx: BillingContext, input: ExtendTrialManuallyInput): Promise<Ok<TrialExtensionResult> | Err<TrialExtensionErrorCode>> {
|
|
37
|
+
if (!isUserId(input.userId)) return err("billing.account_unknown");
|
|
38
|
+
return ctx.db.transaction(async (tx) => {
|
|
39
|
+
const now = ctx.clock.now();
|
|
40
|
+
// A shared lock: the account cannot be deleted before the extension below.
|
|
41
|
+
const [account] = await tx.select({ createdAt: users.createdAt }).from(users).where(eq(users.id, input.userId)).for("key share");
|
|
42
|
+
if (account === undefined) return err("billing.account_unknown");
|
|
43
|
+
// The derived trial pinned and locked before the check, so a concurrent change is seen here.
|
|
44
|
+
const isPinned = await pinEntitlementRow({ ...ctx, db: tx }, { userId: input.userId, accountCreatedAt: account.createdAt });
|
|
45
|
+
await lockEntitlementRow(tx, input.userId);
|
|
46
|
+
const record = await findEntitlementRecord({ ...ctx, db: tx }, input.userId);
|
|
47
|
+
// The account was locked above and its row pinned.
|
|
48
|
+
if (record === null) throw new Error("@softure-ai/billing: an account vanished while its trial was being extended");
|
|
49
|
+
|
|
50
|
+
const refusal = input.until <= now ? "billing.end_not_in_future" : input.until <= record.trialEndsAt ? "billing.trial_not_extended" : null;
|
|
51
|
+
if (refusal !== null) {
|
|
52
|
+
// The pin is this transaction's own row, nobody else has seen it: undo it, so the refusal writes nothing.
|
|
53
|
+
if (isPinned) await tx.delete(entitlements).where(eq(entitlements.userId, input.userId));
|
|
54
|
+
return err(refusal);
|
|
55
|
+
}
|
|
56
|
+
const next = applyEntitlementEvent(record, { type: "extend_trial", until: input.until }, now);
|
|
57
|
+
// Both of the event's refusals were checked above.
|
|
58
|
+
if (!next.ok) throw new Error(`@softure-ai/billing: extending a trial failed with ${next.error}`);
|
|
59
|
+
await tx
|
|
60
|
+
.update(entitlements)
|
|
61
|
+
.set({ trialEndsAt: next.value.trialEndsAt, updatedAt: now })
|
|
62
|
+
.where(eq(entitlements.userId, input.userId));
|
|
63
|
+
const [row] = await tx
|
|
64
|
+
.insert(trialExtensions)
|
|
65
|
+
.values({ userId: input.userId, extendedBy: input.adminId, extendedAt: now, previousEndsAt: record.trialEndsAt, endsAt: next.value.trialEndsAt })
|
|
66
|
+
.returning();
|
|
67
|
+
if (row === undefined) throw new Error("@softure-ai/billing: recording a trial extension returned no row");
|
|
68
|
+
return ok({ extensionId: row.id, entitlement: resolveEntitlement(next.value, now, getEntitlementPolicy(ctx.config)) });
|
|
69
|
+
});
|
|
70
|
+
}
|
package/src/ui/index.ts
CHANGED
|
@@ -23,3 +23,4 @@ export {
|
|
|
23
23
|
type PaymentRequestRow,
|
|
24
24
|
} from "./payment-requests.js";
|
|
25
25
|
export { PricingTiles, type PricingTilesProps, type PricingTilesSlot } from "./pricing-tiles.js";
|
|
26
|
+
export { TrialForm, type TrialFormAction, type TrialFormProps, type TrialFormSlot } from "./trial-form.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Locale } from "@softure-ai/core";
|
|
4
|
+
import { Button, type ClassNames, createSlotClassGetter, FormError, TextField } from "@softure-ai/ui";
|
|
5
|
+
import { useActionState } from "react";
|
|
6
|
+
import { INITIAL_TRIAL_FORM_STATE, type TrialFormState } from "../contract.js";
|
|
7
|
+
import { EMAIL_FIELD, TRIAL_LAST_DAY_FIELD } from "../fields.js";
|
|
8
|
+
import { getBillingErrorMessage, type BillingMessages } from "../messages/index.js";
|
|
9
|
+
|
|
10
|
+
// The admin form that extends an account's trial for free: the account's email and the trial's
|
|
11
|
+
// new last day. It submits straight to its server action, which checks the admin role first; after
|
|
12
|
+
// an extension it says until when the trial lasts and stays ready for the next.
|
|
13
|
+
|
|
14
|
+
export type TrialFormAction = (previous: TrialFormState, formData: FormData) => Promise<TrialFormState>;
|
|
15
|
+
|
|
16
|
+
export type TrialFormSlot = "root" | "form" | "notice";
|
|
17
|
+
|
|
18
|
+
export interface TrialFormProps {
|
|
19
|
+
readonly action: TrialFormAction;
|
|
20
|
+
readonly messages: BillingMessages;
|
|
21
|
+
/** Locale of the built-in copy of the ui primitives. */
|
|
22
|
+
readonly locale?: Locale;
|
|
23
|
+
readonly classNames?: ClassNames<TrialFormSlot>;
|
|
24
|
+
readonly unstyled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CLASSES: Readonly<Record<TrialFormSlot, string>> = {
|
|
28
|
+
root: "sft:flex sft:flex-col sft:gap-3 sft:font-sans",
|
|
29
|
+
form: "sft:flex sft:flex-col sft:gap-3",
|
|
30
|
+
notice: "sft:m-0 sft:text-sm sft:text-success",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function TrialForm({ action, messages, locale, classNames, unstyled }: TrialFormProps) {
|
|
34
|
+
const [state, formAction, isPending] = useActionState(action, INITIAL_TRIAL_FORM_STATE);
|
|
35
|
+
const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames, unstyled });
|
|
36
|
+
const copy = messages.admin.trial;
|
|
37
|
+
const error = state.error === undefined ? undefined : getBillingErrorMessage(messages, state.error);
|
|
38
|
+
const isEmailError = state.error === "billing.account_unknown";
|
|
39
|
+
const isDayError = state.error === "billing.day_invalid" || state.error === "billing.end_not_in_future" || state.error === "billing.trial_not_extended";
|
|
40
|
+
const isError = state.status === "error";
|
|
41
|
+
// A fresh, empty form after each extension; the typed values after an error.
|
|
42
|
+
const key = `${state.status}:${state.email ?? ""}:${state.lastDay ?? ""}`;
|
|
43
|
+
return (
|
|
44
|
+
<div className={slot("root")}>
|
|
45
|
+
<form action={formAction} className={slot("form")}>
|
|
46
|
+
<TextField
|
|
47
|
+
key={`email:${key}`}
|
|
48
|
+
name={EMAIL_FIELD}
|
|
49
|
+
type="email"
|
|
50
|
+
label={copy.email}
|
|
51
|
+
required
|
|
52
|
+
defaultValue={isError ? (state.email ?? "") : ""}
|
|
53
|
+
error={isEmailError ? error : undefined}
|
|
54
|
+
locale={locale}
|
|
55
|
+
unstyled={unstyled}
|
|
56
|
+
/>
|
|
57
|
+
<TextField
|
|
58
|
+
key={`day:${key}`}
|
|
59
|
+
name={TRIAL_LAST_DAY_FIELD}
|
|
60
|
+
type="date"
|
|
61
|
+
label={copy.lastDay}
|
|
62
|
+
required
|
|
63
|
+
defaultValue={isError ? (state.lastDay ?? "") : ""}
|
|
64
|
+
error={isDayError ? error : undefined}
|
|
65
|
+
locale={locale}
|
|
66
|
+
unstyled={unstyled}
|
|
67
|
+
/>
|
|
68
|
+
<FormError message={isEmailError || isDayError ? undefined : error} unstyled={unstyled} />
|
|
69
|
+
<Button type="submit" variant="primary" pending={isPending} unstyled={unstyled}>
|
|
70
|
+
{isPending ? copy.pending : copy.submit}
|
|
71
|
+
</Button>
|
|
72
|
+
</form>
|
|
73
|
+
{state.status === "extended" ? (
|
|
74
|
+
<p role="status" className={slot("notice")}>
|
|
75
|
+
{state.notice}
|
|
76
|
+
</p>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|