@vex-chat/spire 2.4.0 → 2.6.0
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/dist/BillingVerification.d.ts +40 -0
- package/dist/BillingVerification.js +535 -0
- package/dist/BillingVerification.js.map +1 -0
- package/dist/Database.d.ts +39 -1
- package/dist/Database.js +285 -2
- package/dist/Database.js.map +1 -1
- package/dist/db/schema.d.ts +50 -0
- package/dist/migrations/2026-06-24_account-entitlements.d.ts +8 -0
- package/dist/migrations/2026-06-24_account-entitlements.js +20 -0
- package/dist/migrations/2026-06-24_account-entitlements.js.map +1 -0
- package/dist/migrations/2026-07-02_store-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js +83 -0
- package/dist/migrations/2026-07-02_store-subscriptions.js.map +1 -0
- package/dist/server/billing.d.ts +14 -0
- package/dist/server/billing.js +234 -0
- package/dist/server/billing.js.map +1 -0
- package/dist/server/entitlements.d.ts +8 -0
- package/dist/server/entitlements.js +71 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/index.js +6 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BillingVerification.ts +800 -0
- package/src/Database.ts +422 -2
- package/src/__tests__/Database.spec.ts +6 -3
- package/src/__tests__/billing.spec.ts +282 -0
- package/src/__tests__/entitlements.spec.ts +241 -0
- package/src/db/schema.ts +66 -1
- package/src/migrations/2026-06-24_account-entitlements.ts +23 -0
- package/src/migrations/2026-07-02_store-subscriptions.ts +92 -0
- package/src/server/billing.ts +361 -0
- package/src/server/entitlements.ts +104 -0
- package/src/server/index.ts +6 -0
package/src/Database.ts
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
import type { PasskeyRow, ServerDatabase } from "./db/schema.ts";
|
|
8
8
|
import type { SpireOptions } from "./Spire.ts";
|
|
9
9
|
import type {
|
|
10
|
+
AccountEntitlements,
|
|
11
|
+
AccountEntitlementSource,
|
|
12
|
+
AccountTier,
|
|
13
|
+
BillingAccountState,
|
|
14
|
+
BillingEnvironment,
|
|
15
|
+
BillingPlatform,
|
|
16
|
+
BillingSubscription,
|
|
17
|
+
BillingSubscriptionStatus,
|
|
10
18
|
Channel,
|
|
11
19
|
Device,
|
|
12
20
|
DevicePayload,
|
|
@@ -27,7 +35,7 @@ import type {
|
|
|
27
35
|
import type { Migration, MigrationProvider } from "kysely";
|
|
28
36
|
|
|
29
37
|
import { EventEmitter } from "events";
|
|
30
|
-
import { pbkdf2Sync } from "node:crypto";
|
|
38
|
+
import { createHash, pbkdf2Sync } from "node:crypto";
|
|
31
39
|
import { statSync } from "node:fs";
|
|
32
40
|
import * as fs from "node:fs/promises";
|
|
33
41
|
import path from "node:path";
|
|
@@ -38,12 +46,43 @@ import {
|
|
|
38
46
|
getCryptoProfile,
|
|
39
47
|
XUtils,
|
|
40
48
|
} from "@vex-chat/crypto";
|
|
41
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
AccountEntitlementSourceSchema,
|
|
51
|
+
AccountTierSchema,
|
|
52
|
+
BillingEnvironmentSchema,
|
|
53
|
+
BillingPlatformSchema,
|
|
54
|
+
BillingSubscriptionStatusSchema,
|
|
55
|
+
buildAccountEntitlements,
|
|
56
|
+
MailType,
|
|
57
|
+
} from "@vex-chat/types";
|
|
42
58
|
|
|
43
59
|
import argon2 from "argon2";
|
|
44
60
|
|
|
45
61
|
import { serverMailRetentionCutoffIso } from "./mailRetention.ts";
|
|
46
62
|
|
|
63
|
+
export interface StoreSubscriptionUpsertInput {
|
|
64
|
+
environment: BillingEnvironment;
|
|
65
|
+
expiresAt: null | string;
|
|
66
|
+
externalOriginalID?: null | string | undefined;
|
|
67
|
+
externalTransactionID?: null | string | undefined;
|
|
68
|
+
platform: BillingPlatform;
|
|
69
|
+
productID: string;
|
|
70
|
+
purchaseToken?: null | string | undefined;
|
|
71
|
+
rawPayload: unknown;
|
|
72
|
+
status: BillingSubscriptionStatus;
|
|
73
|
+
storeProductID: string;
|
|
74
|
+
tier: AccountTier;
|
|
75
|
+
userID: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface StoreTransactionRecordInput {
|
|
79
|
+
eventType: string;
|
|
80
|
+
externalTransactionID?: null | string | undefined;
|
|
81
|
+
rawPayload: unknown;
|
|
82
|
+
subscriptionID: string;
|
|
83
|
+
userID: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
47
86
|
/**
|
|
48
87
|
* Narrow a plain integer from the `mailType` SQL column to the
|
|
49
88
|
* `MailType` union (0 = initial, 1 = subsequent). Throws if the
|
|
@@ -803,6 +842,78 @@ export class Database extends EventEmitter {
|
|
|
803
842
|
.executeTakeFirst();
|
|
804
843
|
}
|
|
805
844
|
|
|
845
|
+
public async recalculateStoreEntitlements(
|
|
846
|
+
userID: string,
|
|
847
|
+
): Promise<AccountEntitlements> {
|
|
848
|
+
const subscriptions = await this.retrieveBillingSubscriptions(userID);
|
|
849
|
+
const nowMs = Date.now();
|
|
850
|
+
const active = subscriptions
|
|
851
|
+
.filter((subscription) =>
|
|
852
|
+
billingStatusCarriesEntitlement(subscription.status),
|
|
853
|
+
)
|
|
854
|
+
.filter((subscription) => {
|
|
855
|
+
if (!subscription.expiresAt) {
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
const expiresAtMs = Date.parse(subscription.expiresAt);
|
|
859
|
+
return Number.isFinite(expiresAtMs) && expiresAtMs > nowMs;
|
|
860
|
+
})
|
|
861
|
+
.sort((a, b) => {
|
|
862
|
+
const tierDelta =
|
|
863
|
+
accountTierRank(b.tier) - accountTierRank(a.tier);
|
|
864
|
+
if (tierDelta !== 0) {
|
|
865
|
+
return tierDelta;
|
|
866
|
+
}
|
|
867
|
+
return expiryRank(b.expiresAt) - expiryRank(a.expiresAt);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
const best = active[0];
|
|
871
|
+
if (best) {
|
|
872
|
+
return this.setAccountEntitlementTier(userID, best.tier, {
|
|
873
|
+
expiresAt: best.expiresAt,
|
|
874
|
+
source: "store",
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const current = await this.retrieveAccountEntitlements(userID);
|
|
879
|
+
if (current.source !== "store") {
|
|
880
|
+
return current;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return this.setAccountEntitlementTier(userID, "free", {
|
|
884
|
+
expiresAt: null,
|
|
885
|
+
source: "store",
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
public async recordStoreTransaction(
|
|
890
|
+
input: StoreTransactionRecordInput,
|
|
891
|
+
): Promise<void> {
|
|
892
|
+
const subscription = await this.db
|
|
893
|
+
.selectFrom("billing_store_subscriptions")
|
|
894
|
+
.selectAll()
|
|
895
|
+
.where("subscriptionID", "=", input.subscriptionID)
|
|
896
|
+
.limit(1)
|
|
897
|
+
.executeTakeFirstOrThrow();
|
|
898
|
+
|
|
899
|
+
await this.db
|
|
900
|
+
.insertInto("billing_store_transactions")
|
|
901
|
+
.values({
|
|
902
|
+
environment: subscription.environment,
|
|
903
|
+
eventType: input.eventType,
|
|
904
|
+
externalTransactionID: input.externalTransactionID ?? null,
|
|
905
|
+
platform: subscription.platform,
|
|
906
|
+
processedAt: new Date().toISOString(),
|
|
907
|
+
purchaseTokenHash: subscription.purchaseTokenHash,
|
|
908
|
+
rawPayload: JSON.stringify(input.rawPayload),
|
|
909
|
+
storeProductID: subscription.storeProductID,
|
|
910
|
+
subscriptionID: input.subscriptionID,
|
|
911
|
+
transactionID: crypto.randomUUID(),
|
|
912
|
+
userID: input.userID,
|
|
913
|
+
})
|
|
914
|
+
.execute();
|
|
915
|
+
}
|
|
916
|
+
|
|
806
917
|
public async recoverDevice(
|
|
807
918
|
owner: string,
|
|
808
919
|
payload: DevicePayload,
|
|
@@ -945,6 +1056,29 @@ export class Database extends EventEmitter {
|
|
|
945
1056
|
});
|
|
946
1057
|
}
|
|
947
1058
|
|
|
1059
|
+
public async retrieveAccountEntitlements(
|
|
1060
|
+
userID: string,
|
|
1061
|
+
): Promise<AccountEntitlements> {
|
|
1062
|
+
const row = await this.db
|
|
1063
|
+
.selectFrom("account_entitlements")
|
|
1064
|
+
.selectAll()
|
|
1065
|
+
.where("userID", "=", userID)
|
|
1066
|
+
.limit(1)
|
|
1067
|
+
.executeTakeFirst();
|
|
1068
|
+
|
|
1069
|
+
if (!row) {
|
|
1070
|
+
return buildAccountEntitlements({ userID });
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return buildAccountEntitlements({
|
|
1074
|
+
expiresAt: row.expiresAt,
|
|
1075
|
+
refreshedAt: row.updatedAt,
|
|
1076
|
+
source: parseAccountEntitlementSource(row.source),
|
|
1077
|
+
tier: parseAccountTier(row.tier),
|
|
1078
|
+
userID,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
948
1082
|
/**
|
|
949
1083
|
* Retrives a list of users that should be notified when a specific resourceID
|
|
950
1084
|
* experiences changes.
|
|
@@ -968,6 +1102,28 @@ export class Database extends EventEmitter {
|
|
|
968
1102
|
return users;
|
|
969
1103
|
}
|
|
970
1104
|
|
|
1105
|
+
public async retrieveBillingAccountState(
|
|
1106
|
+
userID: string,
|
|
1107
|
+
): Promise<BillingAccountState> {
|
|
1108
|
+
return {
|
|
1109
|
+
entitlements: await this.retrieveAccountEntitlements(userID),
|
|
1110
|
+
subscriptions: await this.retrieveBillingSubscriptions(userID),
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
public async retrieveBillingSubscriptions(
|
|
1115
|
+
userID: string,
|
|
1116
|
+
): Promise<BillingSubscription[]> {
|
|
1117
|
+
const rows = await this.db
|
|
1118
|
+
.selectFrom("billing_store_subscriptions")
|
|
1119
|
+
.selectAll()
|
|
1120
|
+
.where("userID", "=", userID)
|
|
1121
|
+
.orderBy("updatedAt", "desc")
|
|
1122
|
+
.execute();
|
|
1123
|
+
|
|
1124
|
+
return rows.map(toBillingSubscription);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
971
1127
|
public async retrieveChannel(channelID: string): Promise<Channel | null> {
|
|
972
1128
|
const channels: Channel[] = await this.db
|
|
973
1129
|
.selectFrom("channels")
|
|
@@ -1337,6 +1493,37 @@ export class Database extends EventEmitter {
|
|
|
1337
1493
|
return rows.map(toServer);
|
|
1338
1494
|
}
|
|
1339
1495
|
|
|
1496
|
+
public async retrieveStoreSubscriptionOwner(args: {
|
|
1497
|
+
environment: BillingEnvironment;
|
|
1498
|
+
externalOriginalID?: null | string | undefined;
|
|
1499
|
+
platform: BillingPlatform;
|
|
1500
|
+
purchaseToken?: null | string | undefined;
|
|
1501
|
+
}): Promise<null | string> {
|
|
1502
|
+
const purchaseTokenHash = args.purchaseToken
|
|
1503
|
+
? billingPurchaseTokenHash(
|
|
1504
|
+
args.platform,
|
|
1505
|
+
args.environment,
|
|
1506
|
+
args.purchaseToken,
|
|
1507
|
+
)
|
|
1508
|
+
: null;
|
|
1509
|
+
const existing = await this.findExistingStoreSubscription({
|
|
1510
|
+
environment: args.environment,
|
|
1511
|
+
externalOriginalID: args.externalOriginalID ?? null,
|
|
1512
|
+
platform: args.platform,
|
|
1513
|
+
purchaseTokenHash,
|
|
1514
|
+
});
|
|
1515
|
+
if (!existing) {
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
const row = await this.db
|
|
1519
|
+
.selectFrom("billing_store_subscriptions")
|
|
1520
|
+
.select(["userID"])
|
|
1521
|
+
.where("subscriptionID", "=", existing.subscriptionID)
|
|
1522
|
+
.limit(1)
|
|
1523
|
+
.executeTakeFirst();
|
|
1524
|
+
return row?.userID ?? null;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1340
1527
|
// The identifier is matched as either a userID (UUID branch) or a
|
|
1341
1528
|
// username (string branch). Username comparison is case-folded so
|
|
1342
1529
|
// `User` and `user` resolve to the same row regardless of how the
|
|
@@ -1499,6 +1686,147 @@ export class Database extends EventEmitter {
|
|
|
1499
1686
|
}
|
|
1500
1687
|
}
|
|
1501
1688
|
|
|
1689
|
+
public async setAccountEntitlementTier(
|
|
1690
|
+
userID: string,
|
|
1691
|
+
tier: AccountTier,
|
|
1692
|
+
options?: {
|
|
1693
|
+
expiresAt?: null | string | undefined;
|
|
1694
|
+
source?: AccountEntitlementSource | undefined;
|
|
1695
|
+
},
|
|
1696
|
+
): Promise<AccountEntitlements> {
|
|
1697
|
+
const parsedTier = AccountTierSchema.parse(tier);
|
|
1698
|
+
const source = AccountEntitlementSourceSchema.parse(
|
|
1699
|
+
options?.source ?? "dev_override",
|
|
1700
|
+
);
|
|
1701
|
+
const expiresAt = options?.expiresAt ?? null;
|
|
1702
|
+
const updatedAt = new Date().toISOString();
|
|
1703
|
+
|
|
1704
|
+
await this.db
|
|
1705
|
+
.insertInto("account_entitlements")
|
|
1706
|
+
.values({
|
|
1707
|
+
expiresAt,
|
|
1708
|
+
source,
|
|
1709
|
+
tier: parsedTier,
|
|
1710
|
+
updatedAt,
|
|
1711
|
+
userID,
|
|
1712
|
+
})
|
|
1713
|
+
.onConflict((oc) =>
|
|
1714
|
+
oc.column("userID").doUpdateSet({
|
|
1715
|
+
expiresAt,
|
|
1716
|
+
source,
|
|
1717
|
+
tier: parsedTier,
|
|
1718
|
+
updatedAt,
|
|
1719
|
+
}),
|
|
1720
|
+
)
|
|
1721
|
+
.execute();
|
|
1722
|
+
|
|
1723
|
+
return buildAccountEntitlements({
|
|
1724
|
+
expiresAt,
|
|
1725
|
+
refreshedAt: updatedAt,
|
|
1726
|
+
source,
|
|
1727
|
+
tier: parsedTier,
|
|
1728
|
+
userID,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
public async upsertStoreSubscription(
|
|
1733
|
+
input: StoreSubscriptionUpsertInput,
|
|
1734
|
+
): Promise<BillingSubscription> {
|
|
1735
|
+
const now = new Date().toISOString();
|
|
1736
|
+
const purchaseTokenHash = input.purchaseToken
|
|
1737
|
+
? billingPurchaseTokenHash(
|
|
1738
|
+
input.platform,
|
|
1739
|
+
input.environment,
|
|
1740
|
+
input.purchaseToken,
|
|
1741
|
+
)
|
|
1742
|
+
: null;
|
|
1743
|
+
const existing = await this.findExistingStoreSubscription({
|
|
1744
|
+
environment: input.environment,
|
|
1745
|
+
externalOriginalID: input.externalOriginalID ?? null,
|
|
1746
|
+
platform: input.platform,
|
|
1747
|
+
purchaseTokenHash,
|
|
1748
|
+
});
|
|
1749
|
+
const subscriptionID = existing?.subscriptionID ?? crypto.randomUUID();
|
|
1750
|
+
const row = {
|
|
1751
|
+
environment: input.environment,
|
|
1752
|
+
expiresAt: input.expiresAt,
|
|
1753
|
+
externalOriginalID: input.externalOriginalID ?? null,
|
|
1754
|
+
externalTransactionID: input.externalTransactionID ?? null,
|
|
1755
|
+
platform: input.platform,
|
|
1756
|
+
productID: input.productID,
|
|
1757
|
+
purchaseToken: input.purchaseToken ?? null,
|
|
1758
|
+
purchaseTokenHash,
|
|
1759
|
+
rawPayload: JSON.stringify(input.rawPayload),
|
|
1760
|
+
status: input.status,
|
|
1761
|
+
storeProductID: input.storeProductID,
|
|
1762
|
+
tier: input.tier,
|
|
1763
|
+
updatedAt: now,
|
|
1764
|
+
userID: input.userID,
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
if (existing) {
|
|
1768
|
+
await this.db
|
|
1769
|
+
.updateTable("billing_store_subscriptions")
|
|
1770
|
+
.set(row)
|
|
1771
|
+
.where("subscriptionID", "=", subscriptionID)
|
|
1772
|
+
.execute();
|
|
1773
|
+
} else {
|
|
1774
|
+
await this.db
|
|
1775
|
+
.insertInto("billing_store_subscriptions")
|
|
1776
|
+
.values({
|
|
1777
|
+
...row,
|
|
1778
|
+
createdAt: now,
|
|
1779
|
+
subscriptionID,
|
|
1780
|
+
})
|
|
1781
|
+
.execute();
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
const saved = await this.db
|
|
1785
|
+
.selectFrom("billing_store_subscriptions")
|
|
1786
|
+
.selectAll()
|
|
1787
|
+
.where("subscriptionID", "=", subscriptionID)
|
|
1788
|
+
.executeTakeFirstOrThrow();
|
|
1789
|
+
|
|
1790
|
+
return toBillingSubscription(saved);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
private async findExistingStoreSubscription(args: {
|
|
1794
|
+
environment: BillingEnvironment;
|
|
1795
|
+
externalOriginalID: null | string;
|
|
1796
|
+
platform: BillingPlatform;
|
|
1797
|
+
purchaseTokenHash: null | string;
|
|
1798
|
+
}): Promise<null | { subscriptionID: string }> {
|
|
1799
|
+
if (args.externalOriginalID) {
|
|
1800
|
+
const byOriginal = await this.db
|
|
1801
|
+
.selectFrom("billing_store_subscriptions")
|
|
1802
|
+
.select(["subscriptionID"])
|
|
1803
|
+
.where("platform", "=", args.platform)
|
|
1804
|
+
.where("environment", "=", args.environment)
|
|
1805
|
+
.where("externalOriginalID", "=", args.externalOriginalID)
|
|
1806
|
+
.limit(1)
|
|
1807
|
+
.executeTakeFirst();
|
|
1808
|
+
if (byOriginal) {
|
|
1809
|
+
return byOriginal;
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
if (args.purchaseTokenHash) {
|
|
1814
|
+
const byToken = await this.db
|
|
1815
|
+
.selectFrom("billing_store_subscriptions")
|
|
1816
|
+
.select(["subscriptionID"])
|
|
1817
|
+
.where("platform", "=", args.platform)
|
|
1818
|
+
.where("environment", "=", args.environment)
|
|
1819
|
+
.where("purchaseTokenHash", "=", args.purchaseTokenHash)
|
|
1820
|
+
.limit(1)
|
|
1821
|
+
.executeTakeFirst();
|
|
1822
|
+
if (byToken) {
|
|
1823
|
+
return byToken;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1502
1830
|
private async init(): Promise<void> {
|
|
1503
1831
|
const migrator = new Migrator({
|
|
1504
1832
|
db: this.db,
|
|
@@ -1588,6 +1916,54 @@ export async function verifyPassword(
|
|
|
1588
1916
|
return { needsRehash: valid, valid };
|
|
1589
1917
|
}
|
|
1590
1918
|
|
|
1919
|
+
function accountTierRank(tier: AccountTier): number {
|
|
1920
|
+
switch (tier) {
|
|
1921
|
+
case "free":
|
|
1922
|
+
return 0;
|
|
1923
|
+
case "plus":
|
|
1924
|
+
return 1;
|
|
1925
|
+
case "pro":
|
|
1926
|
+
return 2;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function billingEnvironmentFromRow(environment: string): BillingEnvironment {
|
|
1931
|
+
const parsed = BillingEnvironmentSchema.safeParse(environment);
|
|
1932
|
+
return parsed.success ? parsed.data : "production";
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
function billingPlatformFromRow(platform: string): BillingPlatform {
|
|
1936
|
+
const parsed = BillingPlatformSchema.safeParse(platform);
|
|
1937
|
+
return parsed.success ? parsed.data : "apple_app_store";
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function billingPurchaseTokenHash(
|
|
1941
|
+
platform: BillingPlatform,
|
|
1942
|
+
environment: BillingEnvironment,
|
|
1943
|
+
token: string,
|
|
1944
|
+
): string {
|
|
1945
|
+
return createHash("sha256")
|
|
1946
|
+
.update(`${platform}:${environment}:${token}`)
|
|
1947
|
+
.digest("hex");
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function billingStatusCarriesEntitlement(
|
|
1951
|
+
status: BillingSubscriptionStatus,
|
|
1952
|
+
): boolean {
|
|
1953
|
+
return (
|
|
1954
|
+
status === "active" ||
|
|
1955
|
+
status === "billing_retry" ||
|
|
1956
|
+
status === "grace_period"
|
|
1957
|
+
);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function billingSubscriptionStatusFromRow(
|
|
1961
|
+
status: string,
|
|
1962
|
+
): BillingSubscriptionStatus {
|
|
1963
|
+
const parsed = BillingSubscriptionStatusSchema.safeParse(status);
|
|
1964
|
+
return parsed.success ? parsed.data : "pending";
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1591
1967
|
function decodeNotificationEvents(events: string): string[] {
|
|
1592
1968
|
try {
|
|
1593
1969
|
const parsed: unknown = JSON.parse(events);
|
|
@@ -1610,6 +1986,14 @@ function encodeNotificationEvents(events: string[]): string {
|
|
|
1610
1986
|
return JSON.stringify(unique.length > 0 ? unique : ["mail"]);
|
|
1611
1987
|
}
|
|
1612
1988
|
|
|
1989
|
+
function expiryRank(expiresAt: null | string): number {
|
|
1990
|
+
if (!expiresAt) {
|
|
1991
|
+
return Number.MAX_SAFE_INTEGER;
|
|
1992
|
+
}
|
|
1993
|
+
const value = Date.parse(expiresAt);
|
|
1994
|
+
return Number.isFinite(value) ? value : 0;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1613
1997
|
// Mirrors `Spire.normalizeRegistrationUsername` — kept in sync so a
|
|
1614
1998
|
// caller invoking `createUser` directly (e.g. tests, future internal
|
|
1615
1999
|
// flows) gets the same lowercase canonicalization the public
|
|
@@ -1627,6 +2011,42 @@ function normalizeRegistrationUsername(
|
|
|
1627
2011
|
return `key_${seed}`;
|
|
1628
2012
|
}
|
|
1629
2013
|
|
|
2014
|
+
function parseAccountEntitlementSource(
|
|
2015
|
+
source: string,
|
|
2016
|
+
): AccountEntitlementSource {
|
|
2017
|
+
const parsed = AccountEntitlementSourceSchema.safeParse(source);
|
|
2018
|
+
return parsed.success ? parsed.data : "default";
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function parseAccountTier(tier: string): AccountTier {
|
|
2022
|
+
const parsed = AccountTierSchema.safeParse(tier);
|
|
2023
|
+
return parsed.success ? parsed.data : "free";
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
function toBillingSubscription(row: {
|
|
2027
|
+
environment: string;
|
|
2028
|
+
expiresAt: null | string;
|
|
2029
|
+
platform: string;
|
|
2030
|
+
productID: string;
|
|
2031
|
+
status: string;
|
|
2032
|
+
storeProductID: string;
|
|
2033
|
+
subscriptionID: string;
|
|
2034
|
+
tier: string;
|
|
2035
|
+
updatedAt: string;
|
|
2036
|
+
}): BillingSubscription {
|
|
2037
|
+
return {
|
|
2038
|
+
environment: billingEnvironmentFromRow(row.environment),
|
|
2039
|
+
expiresAt: row.expiresAt,
|
|
2040
|
+
platform: billingPlatformFromRow(row.platform),
|
|
2041
|
+
productID: row.productID,
|
|
2042
|
+
status: billingSubscriptionStatusFromRow(row.status),
|
|
2043
|
+
storeProductID: row.storeProductID,
|
|
2044
|
+
subscriptionID: row.subscriptionID,
|
|
2045
|
+
tier: parseAccountTier(row.tier),
|
|
2046
|
+
updatedAt: row.updatedAt,
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1630
2050
|
function toDevice(row: {
|
|
1631
2051
|
deleted: number;
|
|
1632
2052
|
deviceID: string;
|
|
@@ -298,23 +298,26 @@ describe("Database", () => {
|
|
|
298
298
|
sender: "sender-a",
|
|
299
299
|
time,
|
|
300
300
|
});
|
|
301
|
+
const baseTimeMs = Date.now() - 60_000;
|
|
302
|
+
const isoTime = (offsetMs: number) =>
|
|
303
|
+
new Date(baseTimeMs + offsetMs).toISOString();
|
|
301
304
|
|
|
302
305
|
await provider["db"]
|
|
303
306
|
.insertInto("mail")
|
|
304
307
|
.values([
|
|
305
308
|
mail(
|
|
306
309
|
"00000000-0000-0000-0000-000000000003",
|
|
307
|
-
|
|
310
|
+
isoTime(2_000),
|
|
308
311
|
"06",
|
|
309
312
|
),
|
|
310
313
|
mail(
|
|
311
314
|
"00000000-0000-0000-0000-000000000001",
|
|
312
|
-
|
|
315
|
+
isoTime(0),
|
|
313
316
|
"04",
|
|
314
317
|
),
|
|
315
318
|
mail(
|
|
316
319
|
"00000000-0000-0000-0000-000000000002",
|
|
317
|
-
|
|
320
|
+
isoTime(1_000),
|
|
318
321
|
"05",
|
|
319
322
|
),
|
|
320
323
|
])
|