@billfn/core 0.2.0 → 0.4.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/errors.d.ts +3 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -2
- package/dist/errors.js.map +1 -1
- package/dist/grants.d.ts +77 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +459 -0
- package/dist/grants.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +24 -2
- package/dist/schema.js.map +1 -1
- package/dist/service.d.ts +21 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +611 -67
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +195 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/service.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { DuplicateKeyError } from '@superfunctions/db';
|
|
2
3
|
import { ok } from '@superfunctions/envelope';
|
|
3
4
|
import { createNamespacedEmitter, createMetricsEmitter } from '@superfunctions/metrics';
|
|
4
5
|
import { createBillFnError } from './errors.js';
|
|
6
|
+
import { applyGrants, insertGrant, listRepairedGrants, readCreateGrantInput, repairGrantEvents, requireFutureExpiry, revokeGrant, toGrantSummary } from './grants.js';
|
|
5
7
|
import { createDefaultBillingAccountResolver, defaultIdFactory, resolvePlan, resolvePrice, toIsoString } from './helpers.js';
|
|
6
8
|
const TABLES = {
|
|
7
9
|
billingAccounts: 'billingAccounts',
|
|
@@ -33,6 +35,23 @@ const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
|
|
|
33
35
|
const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
|
|
34
36
|
/** Provider metadata key naming the billing account that owns a provider record. */
|
|
35
37
|
const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
|
|
38
|
+
/**
|
|
39
|
+
* Subscription metadata key set when an operator imported the subscription:
|
|
40
|
+
* when it was first imported. Its binding to the billing account is the
|
|
41
|
+
* operator's, so a provider adapter does not expect the BillFn ownership
|
|
42
|
+
* metadata a subscription created outside BillFn lacks.
|
|
43
|
+
*/
|
|
44
|
+
export const BILLFN_IMPORTED_AT_METADATA_KEY = 'billfnImportedAt';
|
|
45
|
+
/**
|
|
46
|
+
* Subscription metadata key set on the row an import creates until its
|
|
47
|
+
* binding is accepted (`settleImportBinding`). A pending row is not usable:
|
|
48
|
+
* webhooks and replays leave events for it unlinked, other writes refuse it
|
|
49
|
+
* (retryably) and subject, account and sync lookups skip it. A refusal deletes
|
|
50
|
+
* it, on the import's first run or on any rerun, so it never becomes durable.
|
|
51
|
+
*/
|
|
52
|
+
export const BILLFN_IMPORT_PENDING_METADATA_KEY = 'billfnImportPending';
|
|
53
|
+
/** `details.reason` of the retryable `BILLFN_CONFLICT` an import raises when its replay did not finish. */
|
|
54
|
+
export const BILLFN_IMPORT_REPLAY_UNFINISHED_REASON = 'import_replay_unfinished';
|
|
36
55
|
/** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
|
|
37
56
|
const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
|
|
38
57
|
/**
|
|
@@ -78,6 +97,34 @@ function createBillFnServiceWithInternals(config) {
|
|
|
78
97
|
queue: config.queue
|
|
79
98
|
};
|
|
80
99
|
deps.reconcileProviderState = (subscription, event) => reconcileWindowEvent(subscription, event);
|
|
100
|
+
const grantDeps = {
|
|
101
|
+
...deps,
|
|
102
|
+
recordBillingEvent: (event, db) => recordBillingEvent(db ? { ...deps, db } : deps, event),
|
|
103
|
+
isDuplicateRecordError
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* The account's entitlements: its paid snapshot, or an active grant while that snapshot is `inactive`, and the
|
|
107
|
+
* grants they were derived from. The grants' missing events are repaired on every read (see
|
|
108
|
+
* `repairGrantEvents`). While the paid snapshot decides and the grants are not asked for, that repair is all
|
|
109
|
+
* that reads them, and it never fails the read; otherwise their status is derived after the paid snapshot read
|
|
110
|
+
* and the repair (see `listRepairedGrants`).
|
|
111
|
+
*/
|
|
112
|
+
async function readEntitlements(billingAccountId, withGrantList = false) {
|
|
113
|
+
const paid = await findEntitlementSnapshot(deps, billingAccountId);
|
|
114
|
+
if (!withGrantList && paid && paid.status !== 'inactive') {
|
|
115
|
+
await repairGrantEvents(grantDeps, billingAccountId);
|
|
116
|
+
return { entitlements: paid, grants: [] };
|
|
117
|
+
}
|
|
118
|
+
const grants = await listRepairedGrants(grantDeps, billingAccountId);
|
|
119
|
+
return { entitlements: await applyGrants(grantDeps, billingAccountId, paid, grants), grants };
|
|
120
|
+
}
|
|
121
|
+
async function findEffectiveEntitlements(billingAccountId) {
|
|
122
|
+
return (await readEntitlements(billingAccountId)).entitlements;
|
|
123
|
+
}
|
|
124
|
+
/** A write's paid snapshot as the account's entitlements (see `applyGrants`). */
|
|
125
|
+
async function withGrants(billingAccountId, paid) {
|
|
126
|
+
return (await applyGrants(grantDeps, billingAccountId, paid)) ?? paid;
|
|
127
|
+
}
|
|
81
128
|
const subscriptionProvider = {
|
|
82
129
|
getActiveSubscription: async (subject) => {
|
|
83
130
|
const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
|
|
@@ -91,7 +138,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
91
138
|
if (!billingAccount) {
|
|
92
139
|
return null;
|
|
93
140
|
}
|
|
94
|
-
return
|
|
141
|
+
return findEffectiveEntitlements(billingAccount.id);
|
|
95
142
|
},
|
|
96
143
|
isActive: async (subject) => {
|
|
97
144
|
const snapshot = await subscriptionProvider.getEntitlementSnapshot(subject);
|
|
@@ -112,8 +159,10 @@ function createBillFnServiceWithInternals(config) {
|
|
|
112
159
|
tenantId: input.tenantId
|
|
113
160
|
};
|
|
114
161
|
const resource = input.resource ?? 'storage';
|
|
115
|
-
|
|
116
|
-
|
|
162
|
+
// Status and limit come from one entitlements read, so a grant ending between reads cannot mix them.
|
|
163
|
+
const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
|
|
164
|
+
const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
|
|
165
|
+
if (!billingAccount || !entitlements || entitlements.status === 'inactive') {
|
|
117
166
|
return {
|
|
118
167
|
allowed: false,
|
|
119
168
|
current: 0,
|
|
@@ -121,19 +170,20 @@ function createBillFnServiceWithInternals(config) {
|
|
|
121
170
|
reason: 'subscription_inactive'
|
|
122
171
|
};
|
|
123
172
|
}
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
173
|
+
const meter = await findUsageMeter(deps, billingAccount.id, resource);
|
|
174
|
+
const current = meter?.current ?? 0;
|
|
175
|
+
const limit = entitlements.limits[resource] ?? 0;
|
|
176
|
+
if (limit >= 0 && current + input.requestedBytes > limit) {
|
|
127
177
|
return {
|
|
128
178
|
allowed: false,
|
|
129
|
-
current
|
|
179
|
+
current,
|
|
130
180
|
limit,
|
|
131
181
|
reason: 'quota_exceeded'
|
|
132
182
|
};
|
|
133
183
|
}
|
|
134
184
|
return {
|
|
135
185
|
allowed: true,
|
|
136
|
-
current
|
|
186
|
+
current,
|
|
137
187
|
limit
|
|
138
188
|
};
|
|
139
189
|
},
|
|
@@ -152,7 +202,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
152
202
|
};
|
|
153
203
|
const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
|
|
154
204
|
const meter = billingAccount ? await findUsageMeter(deps, billingAccount.id, resource) : null;
|
|
155
|
-
const entitlements = billingAccount ? await
|
|
205
|
+
const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
|
|
156
206
|
return {
|
|
157
207
|
current: meter?.current ?? 0,
|
|
158
208
|
limit: entitlements?.limits[resource] ?? 0
|
|
@@ -315,7 +365,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
315
365
|
});
|
|
316
366
|
return {
|
|
317
367
|
subscription: projection.subscription,
|
|
318
|
-
entitlements: projection.entitlements,
|
|
368
|
+
entitlements: await withGrants(billingAccount.id, projection.entitlements),
|
|
319
369
|
operationStatus: operation.operationStatus,
|
|
320
370
|
clientAction: operation.clientAction
|
|
321
371
|
};
|
|
@@ -595,7 +645,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
595
645
|
return ok({
|
|
596
646
|
checkoutSession: result.checkoutSession,
|
|
597
647
|
subscription: result.subscription,
|
|
598
|
-
entitlements: result.entitlements
|
|
648
|
+
entitlements: await withGrants(billingAccount.id, result.entitlements)
|
|
599
649
|
});
|
|
600
650
|
},
|
|
601
651
|
async cancelSubscription(input) {
|
|
@@ -791,7 +841,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
791
841
|
return ok({
|
|
792
842
|
refund,
|
|
793
843
|
subscription: projectedSubscription,
|
|
794
|
-
entitlements: projectedEntitlements,
|
|
844
|
+
entitlements: await applyGrants(grantDeps, billingAccount.id, projectedEntitlements),
|
|
795
845
|
operationStatus: operation.operationStatus,
|
|
796
846
|
clientAction: operation.clientAction
|
|
797
847
|
});
|
|
@@ -809,7 +859,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
809
859
|
const applied = await syncSubscriptionById(subscription.id);
|
|
810
860
|
const response = {
|
|
811
861
|
subscription: applied.subscription,
|
|
812
|
-
entitlements: applied.entitlements
|
|
862
|
+
entitlements: await withGrants(billingAccount.id, applied.entitlements)
|
|
813
863
|
};
|
|
814
864
|
return ok(response);
|
|
815
865
|
},
|
|
@@ -869,7 +919,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
869
919
|
});
|
|
870
920
|
const response = {
|
|
871
921
|
subscription: applied.subscription,
|
|
872
|
-
entitlements: applied.entitlements
|
|
922
|
+
entitlements: await withGrants(billingAccount.id, applied.entitlements)
|
|
873
923
|
};
|
|
874
924
|
return ok(response);
|
|
875
925
|
}
|
|
@@ -887,7 +937,7 @@ function createBillFnServiceWithInternals(config) {
|
|
|
887
937
|
});
|
|
888
938
|
const response = {
|
|
889
939
|
subscription: applied.subscription,
|
|
890
|
-
entitlements: applied.entitlements
|
|
940
|
+
entitlements: await withGrants(billingAccount.id, applied.entitlements)
|
|
891
941
|
};
|
|
892
942
|
return ok(response);
|
|
893
943
|
}
|
|
@@ -896,6 +946,155 @@ function createBillFnServiceWithInternals(config) {
|
|
|
896
946
|
message: 'No restored purchase could be linked to the resolved billing account'
|
|
897
947
|
});
|
|
898
948
|
},
|
|
949
|
+
async importSubscription(input) {
|
|
950
|
+
const { provider: providerName, reference, operator } = readImportRequest(input);
|
|
951
|
+
const plan = requirePlan(deps.catalog, input.planKey);
|
|
952
|
+
if (!plan.prices.some((price) => price.provider === providerName && price.kind === 'subscription')) {
|
|
953
|
+
throw createBillFnError({
|
|
954
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
955
|
+
message: `Plan ${plan.planKey} has no ${providerName} subscription price`
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
const provider = requireProvider(deps, providerName, 'importSubscription');
|
|
959
|
+
// A reference replaced by a subscription write, or bound to another billing account, fails before the provider is read.
|
|
960
|
+
// A pending row (an import not yet accepted, perhaps interrupted) is checked like an unbound reference and
|
|
961
|
+
// removed when the reference is retired, so a rerun of a refused import converges to no binding.
|
|
962
|
+
const bound = await findSubscriptionByProviderRef(deps, providerName, reference);
|
|
963
|
+
if (!bound || isImportPending(bound)) {
|
|
964
|
+
await refuseRetiredImportReference(deps, providerName, reference, bound);
|
|
965
|
+
}
|
|
966
|
+
if (bound && bound.billingAccountId !== input.billingAccountId) {
|
|
967
|
+
throw importBoundElsewhere(providerName);
|
|
968
|
+
}
|
|
969
|
+
// Resolved after the checks above, so an import they refuse never creates the subject's billing account;
|
|
970
|
+
// one refused after the provider read keeps it.
|
|
971
|
+
const billingAccount = await resolveOperatorBillingAccount(deps, input);
|
|
972
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
973
|
+
const importObservedAt = toIsoString(deps.now());
|
|
974
|
+
const state = await readImportState(provider, providerName, { providerReference: reference, billingAccount, plan });
|
|
975
|
+
if (!state) {
|
|
976
|
+
throw createBillFnError({
|
|
977
|
+
code: 'BILLFN_NOT_FOUND',
|
|
978
|
+
message: 'Provider did not return the subscription to import'
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
if (state.providerSubscriptionId !== reference) {
|
|
982
|
+
throw createBillFnError({
|
|
983
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
984
|
+
message: 'The provider record does not match the imported reference'
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
const selection = requireImportPrice(plan, providerName, state);
|
|
988
|
+
// One audit event per import run and state it records, never changed once written. The run is the operator's
|
|
989
|
+
// request (a rerun of it that records a state it already recorded adds none, even after another state) or,
|
|
990
|
+
// without a requestId, this call. The state is every field the event shows and whether the row carries the
|
|
991
|
+
// import marker (not its value, which a rerun that adds it sets to its own read time).
|
|
992
|
+
const run = operator.requestId ? ['request', operator.requestId] : ['call', deps.generateId('run')];
|
|
993
|
+
const importAudit = (subscription) => ({
|
|
994
|
+
id: `evt_import_${createHash('sha256')
|
|
995
|
+
.update(JSON.stringify([
|
|
996
|
+
providerName,
|
|
997
|
+
reference,
|
|
998
|
+
billingAccount.id,
|
|
999
|
+
operator.id,
|
|
1000
|
+
operator.type ?? null,
|
|
1001
|
+
...run,
|
|
1002
|
+
plan.planKey,
|
|
1003
|
+
input.reason ?? null,
|
|
1004
|
+
subscription.id,
|
|
1005
|
+
subscription.planKey,
|
|
1006
|
+
subscription.priceId,
|
|
1007
|
+
subscription.status,
|
|
1008
|
+
subscription.currentPeriodStart ?? null,
|
|
1009
|
+
subscription.currentPeriodEnd ?? null,
|
|
1010
|
+
subscription.autoRenew,
|
|
1011
|
+
subscription.metadata?.[BILLFN_IMPORTED_AT_METADATA_KEY] !== undefined
|
|
1012
|
+
]))
|
|
1013
|
+
.digest('base64url')}`,
|
|
1014
|
+
billingAccountId: billingAccount.id,
|
|
1015
|
+
type: 'billfn.subscription.imported',
|
|
1016
|
+
payload: {
|
|
1017
|
+
subscriptionId: subscription.id,
|
|
1018
|
+
provider: providerName,
|
|
1019
|
+
providerReference: reference,
|
|
1020
|
+
planKey: subscription.planKey,
|
|
1021
|
+
priceId: subscription.priceId,
|
|
1022
|
+
status: subscription.status,
|
|
1023
|
+
currentPeriodStart: subscription.currentPeriodStart ?? null,
|
|
1024
|
+
currentPeriodEnd: subscription.currentPeriodEnd ?? null,
|
|
1025
|
+
autoRenew: subscription.autoRenew,
|
|
1026
|
+
operator,
|
|
1027
|
+
...(input.reason ? { reason: input.reason } : {})
|
|
1028
|
+
},
|
|
1029
|
+
createdAt: toIsoString(deps.now())
|
|
1030
|
+
});
|
|
1031
|
+
let applied;
|
|
1032
|
+
try {
|
|
1033
|
+
// Racing imports end with one row: the loser of the unique reference insert applies an ordered update,
|
|
1034
|
+
// or fails as bound elsewhere when the winner bound another billing account.
|
|
1035
|
+
applied = await applyVerificationState(deps, {
|
|
1036
|
+
billingAccount,
|
|
1037
|
+
plan: selection.plan,
|
|
1038
|
+
price: selection.price,
|
|
1039
|
+
state,
|
|
1040
|
+
liveStateObservedAt: importObservedAt,
|
|
1041
|
+
// A rerun or a racing import keeps the first import's time.
|
|
1042
|
+
metadataDefaults: { [BILLFN_IMPORTED_AT_METADATA_KEY]: importObservedAt },
|
|
1043
|
+
// A write may retire the reference while the provider is read (the check above ran before the read).
|
|
1044
|
+
importReference: reference,
|
|
1045
|
+
importAudit
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
catch (error) {
|
|
1049
|
+
if (hasErrorCode(error, 'BILLFN_CONFLICT') && (await isBoundElsewhere(deps, providerName, state, billingAccount.id))) {
|
|
1050
|
+
throw importBoundElsewhere(providerName);
|
|
1051
|
+
}
|
|
1052
|
+
throw error;
|
|
1053
|
+
}
|
|
1054
|
+
// Each write that left the row usable recorded its state's audit first. A run that wrote nothing (a read an
|
|
1055
|
+
// event superseded, or another run's accepted row) records the state it applied here, before the replay
|
|
1056
|
+
// changes anything, so a replay that does not finish still leaves this operator's event.
|
|
1057
|
+
await recordBillingEvent(deps, importAudit(applied.subscription));
|
|
1058
|
+
// A replay that does not finish fails the import; calling it again finishes the replay.
|
|
1059
|
+
try {
|
|
1060
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, applied.subscription);
|
|
1061
|
+
}
|
|
1062
|
+
catch (error) {
|
|
1063
|
+
if (isReconciliationLeaseConflict(error)) {
|
|
1064
|
+
throw importReplayUnfinished();
|
|
1065
|
+
}
|
|
1066
|
+
throw error;
|
|
1067
|
+
}
|
|
1068
|
+
const refreshed = await refreshProjectionAfterReplay(deps, applied);
|
|
1069
|
+
// The event returned is the one for the state returned (after the replay), recorded here when the replay
|
|
1070
|
+
// changed it. The first write wins, so a rerun returns it as recorded.
|
|
1071
|
+
const recorded = importAudit(refreshed.subscription);
|
|
1072
|
+
await recordBillingEvent(deps, recorded);
|
|
1073
|
+
const billingEvent = (await deps.db.findOne({
|
|
1074
|
+
model: TABLES.billingEvents,
|
|
1075
|
+
where: [{ field: 'id', operator: 'eq', value: recorded.id }],
|
|
1076
|
+
namespace: deps.namespace
|
|
1077
|
+
})) ?? recorded;
|
|
1078
|
+
deps.metrics.track('subscription.imported', { provider: providerName });
|
|
1079
|
+
return ok({
|
|
1080
|
+
subscription: refreshed.subscription,
|
|
1081
|
+
entitlements: await withGrants(refreshed.subscription.billingAccountId, refreshed.entitlements),
|
|
1082
|
+
billingEvent
|
|
1083
|
+
});
|
|
1084
|
+
},
|
|
1085
|
+
async createGrant(input) {
|
|
1086
|
+
const grantInput = readCreateGrantInput(grantDeps, input);
|
|
1087
|
+
// Before the expiry check, so a refused request still records events an interrupted call left unrecorded.
|
|
1088
|
+
await repairGrantEvents(grantDeps, input.billingAccountId);
|
|
1089
|
+
requireFutureExpiry(grantDeps, grantInput);
|
|
1090
|
+
const billingAccount = await resolveOperatorBillingAccount(deps, input);
|
|
1091
|
+
const grant = await insertGrant(grantDeps, billingAccount.id, grantInput);
|
|
1092
|
+
return ok({ grant, entitlements: await findEffectiveEntitlements(billingAccount.id) });
|
|
1093
|
+
},
|
|
1094
|
+
async revokeGrant(input) {
|
|
1095
|
+
const grant = await revokeGrant(grantDeps, input);
|
|
1096
|
+
return ok({ grant, entitlements: await findEffectiveEntitlements(grant.billingAccountId) });
|
|
1097
|
+
},
|
|
899
1098
|
async getEntitlements(subject) {
|
|
900
1099
|
const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
|
|
901
1100
|
const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
|
|
@@ -903,22 +1102,24 @@ function createBillFnServiceWithInternals(config) {
|
|
|
903
1102
|
return ok({
|
|
904
1103
|
billingAccount: buildVirtualBillingAccount(resolved, deps),
|
|
905
1104
|
entitlements: null,
|
|
906
|
-
subscription: null
|
|
1105
|
+
subscription: null,
|
|
1106
|
+
grants: []
|
|
907
1107
|
});
|
|
908
1108
|
}
|
|
909
|
-
const entitlements = await
|
|
1109
|
+
const { entitlements, grants } = await readEntitlements(billingAccount.id, true);
|
|
910
1110
|
const subscription = await findActiveSubscription(deps, billingAccount.id);
|
|
911
1111
|
const response = {
|
|
912
1112
|
billingAccount,
|
|
913
1113
|
entitlements,
|
|
914
|
-
subscription
|
|
1114
|
+
subscription,
|
|
1115
|
+
grants: grants.map(toGrantSummary)
|
|
915
1116
|
};
|
|
916
1117
|
return ok(response);
|
|
917
1118
|
},
|
|
918
1119
|
async getUsage(subject, resource) {
|
|
919
1120
|
const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
|
|
920
1121
|
const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
|
|
921
|
-
const entitlements = billingAccount ? await
|
|
1122
|
+
const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
|
|
922
1123
|
const meters = billingAccount
|
|
923
1124
|
? resource
|
|
924
1125
|
? await findUsageMeters(deps, billingAccount.id, resource)
|
|
@@ -1427,7 +1628,7 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1427
1628
|
}
|
|
1428
1629
|
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
|
|
1429
1630
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
1430
|
-
if (!subscription) {
|
|
1631
|
+
if (!subscription || isImportPending(subscription)) {
|
|
1431
1632
|
return;
|
|
1432
1633
|
}
|
|
1433
1634
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
@@ -1623,6 +1824,287 @@ function requireProvider(deps, providerName, capability) {
|
|
|
1623
1824
|
}
|
|
1624
1825
|
return provider;
|
|
1625
1826
|
}
|
|
1827
|
+
function readImportRequest(input) {
|
|
1828
|
+
const invalid = (message) => createBillFnError({ code: 'BILLFN_VALIDATION_ERROR', message });
|
|
1829
|
+
const optionalReference = (value, name) => {
|
|
1830
|
+
if (value === undefined) {
|
|
1831
|
+
return undefined;
|
|
1832
|
+
}
|
|
1833
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value !== value.trim()) {
|
|
1834
|
+
throw invalid(`${name} must be a non-empty string without surrounding whitespace`);
|
|
1835
|
+
}
|
|
1836
|
+
return value;
|
|
1837
|
+
};
|
|
1838
|
+
const providerSubscriptionId = optionalReference(input.providerSubscriptionId, 'providerSubscriptionId');
|
|
1839
|
+
const originalTransactionId = optionalReference(input.originalTransactionId, 'originalTransactionId');
|
|
1840
|
+
if (originalTransactionId !== undefined && input.provider !== 'apple') {
|
|
1841
|
+
throw invalid('originalTransactionId identifies Apple subscriptions only');
|
|
1842
|
+
}
|
|
1843
|
+
if (providerSubscriptionId !== undefined && originalTransactionId !== undefined && providerSubscriptionId !== originalTransactionId) {
|
|
1844
|
+
throw invalid('providerSubscriptionId and originalTransactionId name different subscriptions');
|
|
1845
|
+
}
|
|
1846
|
+
const reference = providerSubscriptionId ?? originalTransactionId;
|
|
1847
|
+
if (reference === undefined) {
|
|
1848
|
+
throw invalid('Importing a subscription requires providerSubscriptionId or originalTransactionId');
|
|
1849
|
+
}
|
|
1850
|
+
if (typeof input.billingAccountId !== 'string' || input.billingAccountId.length === 0) {
|
|
1851
|
+
throw invalid('billingAccountId must be a non-empty string');
|
|
1852
|
+
}
|
|
1853
|
+
if (typeof input.planKey !== 'string' || input.planKey.length === 0) {
|
|
1854
|
+
throw invalid('planKey must be a non-empty string');
|
|
1855
|
+
}
|
|
1856
|
+
const operator = input.operator;
|
|
1857
|
+
if (!operator || typeof operator.id !== 'string' || operator.id.length === 0) {
|
|
1858
|
+
throw invalid('Importing a subscription requires the operator identity');
|
|
1859
|
+
}
|
|
1860
|
+
if (input.reason !== undefined && typeof input.reason !== 'string') {
|
|
1861
|
+
throw invalid('reason must be a string');
|
|
1862
|
+
}
|
|
1863
|
+
return {
|
|
1864
|
+
provider: input.provider,
|
|
1865
|
+
reference,
|
|
1866
|
+
operator: {
|
|
1867
|
+
id: operator.id,
|
|
1868
|
+
...(typeof operator.type === 'string' ? { type: operator.type } : {}),
|
|
1869
|
+
...(typeof operator.requestId === 'string' ? { requestId: operator.requestId } : {})
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Reads the provider record to import. A BillFn error from the adapter keeps its classification; any other failure
|
|
1875
|
+
* (the provider was not reached, or the adapter failed without classifying it) is a retryable provider error whose
|
|
1876
|
+
* message names only the provider, so no raw error text reaches the operator.
|
|
1877
|
+
*/
|
|
1878
|
+
async function readImportState(provider, providerName, input) {
|
|
1879
|
+
try {
|
|
1880
|
+
return await provider.importSubscription(input);
|
|
1881
|
+
}
|
|
1882
|
+
catch (error) {
|
|
1883
|
+
const code = error?.code;
|
|
1884
|
+
if (typeof code === 'string' && code.startsWith('BILLFN_')) {
|
|
1885
|
+
throw error;
|
|
1886
|
+
}
|
|
1887
|
+
throw createBillFnError({
|
|
1888
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
1889
|
+
message: `Provider ${providerName} could not be read for the imported subscription`
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
/** The billing account an import or grant binds to: created for the subject when one is given, otherwise existing. */
|
|
1894
|
+
async function resolveOperatorBillingAccount(deps, input) {
|
|
1895
|
+
if (input.subject) {
|
|
1896
|
+
const subject = requireSubject(input.subject);
|
|
1897
|
+
const resolved = await resolveBillingAccountReference(deps, subject);
|
|
1898
|
+
if (resolved.billingAccountId !== input.billingAccountId) {
|
|
1899
|
+
throw createBillFnError({
|
|
1900
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1901
|
+
message: 'The subject resolves to a different billing account'
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
return (await getOrCreateBillingAccount(deps, subject)).billingAccount;
|
|
1905
|
+
}
|
|
1906
|
+
const billingAccount = await getBillingAccountById(deps, input.billingAccountId);
|
|
1907
|
+
if (!billingAccount) {
|
|
1908
|
+
throw createBillFnError({
|
|
1909
|
+
code: 'BILLFN_NOT_FOUND',
|
|
1910
|
+
message: 'Billing account not found'
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
return billingAccount;
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* The plan's subscription price the provider record's product names. Every reference (the adapter's
|
|
1917
|
+
* `providerProductId`, then the record's `product_id`/`productId`) is a provider product id here, so it is matched
|
|
1918
|
+
* only against `providerProductId`, never against a catalog `priceId` another plan's product could equal.
|
|
1919
|
+
*/
|
|
1920
|
+
function requireImportPrice(plan, provider, state) {
|
|
1921
|
+
const raw = asRecord(state.raw);
|
|
1922
|
+
const records = [raw, asRecord(raw?.transaction)];
|
|
1923
|
+
const references = [
|
|
1924
|
+
state.providerProductId,
|
|
1925
|
+
...records.flatMap((record) => (record ? ['product_id', 'productId'].map((key) => readString(record, key)) : []))
|
|
1926
|
+
].filter((reference) => Boolean(reference));
|
|
1927
|
+
const prices = plan.prices.filter((price) => price.provider === provider && price.kind === 'subscription');
|
|
1928
|
+
for (const reference of references) {
|
|
1929
|
+
const price = prices.find((candidate) => candidate.providerProductId === reference);
|
|
1930
|
+
if (price) {
|
|
1931
|
+
return { plan, price };
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
throw createBillFnError({
|
|
1935
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1936
|
+
message: `The provider subscription's product is not a ${provider} subscription price of plan ${plan.planKey}`,
|
|
1937
|
+
details: { planKey: plan.planKey, providerProductIds: references }
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
function importBoundElsewhere(provider) {
|
|
1941
|
+
return createBillFnError({
|
|
1942
|
+
code: 'BILLFN_CONFLICT',
|
|
1943
|
+
message: `The ${provider} subscription is already bound to a different billing account`
|
|
1944
|
+
});
|
|
1945
|
+
}
|
|
1946
|
+
/** Whether a subscription of another billing account holds a reference the provider record names. */
|
|
1947
|
+
async function isBoundElsewhere(deps, provider, state, billingAccountId) {
|
|
1948
|
+
const holder = await findSubscriptionByProviderRef(deps, provider, state.providerSubscriptionId, state.providerChargeId);
|
|
1949
|
+
return Boolean(holder && holder.billingAccountId !== billingAccountId);
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* The import is bound and recorded, but another run holds the reference's
|
|
1953
|
+
* unlinked events (including a run interrupted before it released them) or the
|
|
1954
|
+
* replay reached its bound. Unlike a bound-elsewhere conflict it is retryable:
|
|
1955
|
+
* calling import again, after the holder's lease expires, finishes the replay.
|
|
1956
|
+
*/
|
|
1957
|
+
function importReplayUnfinished() {
|
|
1958
|
+
return createBillFnError({
|
|
1959
|
+
code: 'BILLFN_CONFLICT',
|
|
1960
|
+
message: 'The import is recorded but replaying its unlinked webhook events did not finish; import again to finish it',
|
|
1961
|
+
details: { reason: BILLFN_IMPORT_REPLAY_UNFINISHED_REASON },
|
|
1962
|
+
retryable: true
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
function importReferenceReplaced() {
|
|
1966
|
+
return createBillFnError({
|
|
1967
|
+
code: 'BILLFN_CONFLICT',
|
|
1968
|
+
message: 'The provider reference was replaced by another subscription and cannot be imported'
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* Refuses an import of a reference a subscription write retired: deletes
|
|
1973
|
+
* `holder` when it is a pending import row holding the reference (whichever
|
|
1974
|
+
* import run created it), then fails with a non-retryable `BILLFN_CONFLICT`.
|
|
1975
|
+
* An accepted row is never deleted: the delete is ordered against a fresh
|
|
1976
|
+
* read that is still pending, so a stale `holder` another run has accepted
|
|
1977
|
+
* since survives.
|
|
1978
|
+
*/
|
|
1979
|
+
async function refuseRetiredImportReference(deps, provider, reference, holder) {
|
|
1980
|
+
if (!(await isReplacedProviderReference(deps, provider, reference, holder?.id))) {
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
const stored = holder && isImportPending(holder) ? await getSubscriptionById(deps, holder.id) : null;
|
|
1984
|
+
if (stored && isImportPending(stored) && stored.providerSubscriptionId === reference) {
|
|
1985
|
+
await deps.db.deleteMany({
|
|
1986
|
+
model: TABLES.subscriptions,
|
|
1987
|
+
where: [
|
|
1988
|
+
{ field: 'id', operator: 'eq', value: stored.id },
|
|
1989
|
+
{ field: 'providerSubscriptionId', operator: 'eq', value: reference },
|
|
1990
|
+
{ field: 'updatedAt', operator: 'eq', value: stored.updatedAt }
|
|
1991
|
+
],
|
|
1992
|
+
namespace: deps.namespace
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
deps.metrics.track('subscription.import.replaced_reference_refused', { provider });
|
|
1996
|
+
throw importReferenceReplaced();
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* After an import's write to `subscription`, which holds `reference`: refuses
|
|
2000
|
+
* the binding when a write of another subscription retired the reference, or
|
|
2001
|
+
* accepts it by clearing the pending marker, and returns the accepted row:
|
|
2002
|
+
* `ownState` when that row carries this write's state, and `acceptedHere` when
|
|
2003
|
+
* this call cleared its pending marker. A lost acceptance write re-finds the
|
|
2004
|
+
* row, which then carries another run's state (accepted by it or here), so the
|
|
2005
|
+
* caller publishes that row's plan, never its own. Every row, the first and
|
|
2006
|
+
* each reload, is checked the same way before it is accepted or returned: it
|
|
2007
|
+
* holds the reference, belongs to `billingAccountId` (another account's row
|
|
2008
|
+
* fails as bound elsewhere, untouched) and its reference is not retired.
|
|
2009
|
+
* Nothing uses a pending row, so no entitlement or lifecycle event exists for
|
|
2010
|
+
* it until this accepts it. The import's audit event (`audit`) for the row it
|
|
2011
|
+
* accepts is recorded just before each acceptance write, so an accepted
|
|
2012
|
+
* binding always has it (an event for a write that then does not land stays);
|
|
2013
|
+
* a refusal deletes the row, and an interrupted run leaves it pending for the
|
|
2014
|
+
* rerun to settle.
|
|
2015
|
+
*
|
|
2016
|
+
* Why the check after the write is enough: a reference is held by at most one
|
|
2017
|
+
* row (unique index), so a writer that retired it took it off its row before
|
|
2018
|
+
* this write held it, and recorded it before that, or saved it pending in that
|
|
2019
|
+
* same write. Read pending, then recorded, now, one of the reads sees it. Every
|
|
2020
|
+
* import that holds the row therefore reaches the same answer.
|
|
2021
|
+
*/
|
|
2022
|
+
async function settleImportBinding(deps, subscription, reference, billingAccountId, audit) {
|
|
2023
|
+
let current = subscription;
|
|
2024
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2025
|
+
// Only the first attempt holds this run's write; a reload holds whatever wrote the row after it.
|
|
2026
|
+
const ownState = attempt === 0;
|
|
2027
|
+
if (current?.providerSubscriptionId !== reference) {
|
|
2028
|
+
throw createBillFnError({
|
|
2029
|
+
code: 'BILLFN_CONFLICT',
|
|
2030
|
+
message: 'The imported subscription changed while its import was accepted',
|
|
2031
|
+
retryable: true
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
if (current.billingAccountId !== billingAccountId) {
|
|
2035
|
+
throw importBoundElsewhere(current.provider);
|
|
2036
|
+
}
|
|
2037
|
+
await refuseRetiredImportReference(deps, current.provider, reference, current);
|
|
2038
|
+
if (!isImportPending(current)) {
|
|
2039
|
+
return { subscription: current, ownState, acceptedHere: false };
|
|
2040
|
+
}
|
|
2041
|
+
const accepted = {
|
|
2042
|
+
...current,
|
|
2043
|
+
metadata: withoutImportPending(current.metadata),
|
|
2044
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
2045
|
+
};
|
|
2046
|
+
if (await updateSubscriptionAfterImportAudit(deps, current, accepted, audit)) {
|
|
2047
|
+
return { subscription: accepted, ownState, acceptedHere: true };
|
|
2048
|
+
}
|
|
2049
|
+
current = await findSubscriptionByProviderRef(deps, subscription.provider, reference);
|
|
2050
|
+
await Promise.resolve();
|
|
2051
|
+
}
|
|
2052
|
+
throw createBillFnError({
|
|
2053
|
+
code: 'BILLFN_CONFLICT',
|
|
2054
|
+
message: 'Unable to accept the imported subscription after multiple retries',
|
|
2055
|
+
retryable: true
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
/** Whether the row is an import's binding not yet accepted (see `BILLFN_IMPORT_PENDING_METADATA_KEY`). */
|
|
2059
|
+
function isImportPending(subscription) {
|
|
2060
|
+
return subscription?.metadata?.[BILLFN_IMPORT_PENDING_METADATA_KEY] === true;
|
|
2061
|
+
}
|
|
2062
|
+
function withoutImportPending(metadata) {
|
|
2063
|
+
if (!metadata) {
|
|
2064
|
+
return metadata;
|
|
2065
|
+
}
|
|
2066
|
+
const { [BILLFN_IMPORT_PENDING_METADATA_KEY]: _pending, ...rest } = metadata;
|
|
2067
|
+
return rest;
|
|
2068
|
+
}
|
|
2069
|
+
/** A write other than an import's reached a pending import row: retried once the import settles it. */
|
|
2070
|
+
function importPendingConflict() {
|
|
2071
|
+
return createBillFnError({
|
|
2072
|
+
code: 'BILLFN_CONFLICT',
|
|
2073
|
+
message: 'The subscription is being imported',
|
|
2074
|
+
details: { reason: 'subscription_import_pending' },
|
|
2075
|
+
retryable: true
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Whether a subscription write dropped this reference (still pending on the
|
|
2080
|
+
* subscription, or recorded). A writer records the reference before it clears
|
|
2081
|
+
* pending, so pending is read first, as `namesReplacedReference` does: a writer
|
|
2082
|
+
* that clears pending between the reads has already recorded it. `holderId`,
|
|
2083
|
+
* the row that holds the reference now, is not counted: a reference it dropped
|
|
2084
|
+
* and holds again, or one a write that lost its race on it recorded, is held.
|
|
2085
|
+
*/
|
|
2086
|
+
async function isReplacedProviderReference(deps, provider, reference, holderId) {
|
|
2087
|
+
const pending = await deps.db.findMany({
|
|
2088
|
+
model: TABLES.subscriptions,
|
|
2089
|
+
where: [
|
|
2090
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2091
|
+
{ field: 'replacedReferencesPending', operator: 'eq', value: true }
|
|
2092
|
+
],
|
|
2093
|
+
namespace: deps.namespace
|
|
2094
|
+
});
|
|
2095
|
+
if (pending.some((subscription) => subscription.id !== holderId && pendingReplacedReferencesOf(subscription).includes(reference))) {
|
|
2096
|
+
return true;
|
|
2097
|
+
}
|
|
2098
|
+
const replaced = await deps.db.findOne({
|
|
2099
|
+
model: TABLES.replacedProviderReferences,
|
|
2100
|
+
where: [
|
|
2101
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2102
|
+
{ field: 'providerReference', operator: 'eq', value: reference }
|
|
2103
|
+
],
|
|
2104
|
+
namespace: deps.namespace
|
|
2105
|
+
});
|
|
2106
|
+
return Boolean(replaced && replaced.subscriptionId !== holderId);
|
|
2107
|
+
}
|
|
1626
2108
|
function requireSubject(subject) {
|
|
1627
2109
|
if (!subject) {
|
|
1628
2110
|
throw createBillFnError({
|
|
@@ -1678,7 +2160,7 @@ async function getSubscriptionById(deps, id) {
|
|
|
1678
2160
|
}
|
|
1679
2161
|
async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId) {
|
|
1680
2162
|
if (subscriptionId) {
|
|
1681
|
-
|
|
2163
|
+
const subscription = await deps.db.findOne({
|
|
1682
2164
|
model: TABLES.subscriptions,
|
|
1683
2165
|
where: [
|
|
1684
2166
|
{ field: 'id', operator: 'eq', value: subscriptionId },
|
|
@@ -1686,6 +2168,7 @@ async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId)
|
|
|
1686
2168
|
],
|
|
1687
2169
|
namespace: deps.namespace
|
|
1688
2170
|
});
|
|
2171
|
+
return isImportPending(subscription) ? null : subscription;
|
|
1689
2172
|
}
|
|
1690
2173
|
return findActiveSubscription(deps, billingAccountId);
|
|
1691
2174
|
}
|
|
@@ -1699,7 +2182,8 @@ async function findActiveSubscription(deps, billingAccountId) {
|
|
|
1699
2182
|
orderBy: [{ field: 'updatedAt', direction: 'desc' }],
|
|
1700
2183
|
namespace: deps.namespace
|
|
1701
2184
|
});
|
|
1702
|
-
|
|
2185
|
+
// A pending import row is not the account's until its import accepts it.
|
|
2186
|
+
return candidates.find((candidate) => !isImportPending(candidate)) ?? null;
|
|
1703
2187
|
}
|
|
1704
2188
|
async function findSubscriptionByProviderRef(deps, provider, providerSubscriptionId, providerChargeId, checkoutReferenceId) {
|
|
1705
2189
|
const whereSets = [];
|
|
@@ -1999,12 +2483,23 @@ async function applyVerificationState(deps, input) {
|
|
|
1999
2483
|
// A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
|
|
2000
2484
|
const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
|
|
2001
2485
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2486
|
+
if (!input.importReference && isImportPending(baseSubscription)) {
|
|
2487
|
+
// Only the import settles its pending row; anything else applies once it is accepted (or deleted).
|
|
2488
|
+
throw importPendingConflict();
|
|
2489
|
+
}
|
|
2002
2490
|
if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
|
|
2003
2491
|
throw createBillFnError({
|
|
2004
2492
|
code: 'BILLFN_CONFLICT',
|
|
2005
2493
|
message: 'Provider billing reference belongs to a different billing account'
|
|
2006
2494
|
});
|
|
2007
2495
|
}
|
|
2496
|
+
if (input.importReference && baseSubscription && baseSubscription.providerSubscriptionId !== input.importReference) {
|
|
2497
|
+
// Found by another reference the provider record names: an import never moves its reference onto such a row.
|
|
2498
|
+
throw createBillFnError({
|
|
2499
|
+
code: 'BILLFN_CONFLICT',
|
|
2500
|
+
message: 'The provider record names a reference another subscription holds'
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2008
2503
|
const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
|
|
2009
2504
|
if (baseSubscription && order === 'skip') {
|
|
2010
2505
|
// The older event's state is skipped, but a reference it adds still belongs to this subscription.
|
|
@@ -2037,21 +2532,13 @@ async function applyVerificationState(deps, input) {
|
|
|
2037
2532
|
subscription = baseSubscription;
|
|
2038
2533
|
break;
|
|
2039
2534
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
where: [
|
|
2043
|
-
{ field: 'id', operator: 'eq', value: baseSubscription.id },
|
|
2044
|
-
{ field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
|
|
2045
|
-
],
|
|
2046
|
-
data: linked,
|
|
2047
|
-
namespace: deps.namespace
|
|
2048
|
-
});
|
|
2049
|
-
if (updated === 1) {
|
|
2535
|
+
// An import that adds its marker or references to an accepted row records its audit first.
|
|
2536
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, linked, input.importAudit)) {
|
|
2050
2537
|
subscription = linked;
|
|
2051
2538
|
break;
|
|
2052
2539
|
}
|
|
2053
|
-
const current = await
|
|
2054
|
-
if (!current) {
|
|
2540
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2541
|
+
if (!current && !input.importReference) {
|
|
2055
2542
|
throw createBillFnError({
|
|
2056
2543
|
code: 'BILLFN_CONFLICT',
|
|
2057
2544
|
message: 'Subscription changed while applying provider state'
|
|
@@ -2062,16 +2549,20 @@ async function applyVerificationState(deps, input) {
|
|
|
2062
2549
|
continue;
|
|
2063
2550
|
}
|
|
2064
2551
|
// A state write closes the window of an earlier committed mutation; a committed mutation opens its own.
|
|
2065
|
-
const merged = mergeJson(baseSubscription?.metadata, input.state.raw);
|
|
2552
|
+
const merged = mergeJson(mergeJson(input.metadataDefaults, baseSubscription?.metadata), input.state.raw);
|
|
2066
2553
|
const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
|
|
2067
2554
|
const providerStateAt = providerStateTimeFor(input.occurredAt, supersededBefore, baseSubscription);
|
|
2068
|
-
const metadata =
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2555
|
+
const metadata = {
|
|
2556
|
+
...mergedMetadata,
|
|
2557
|
+
...(providerStateAt
|
|
2558
|
+
? {
|
|
2559
|
+
[PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
|
|
2560
|
+
...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
|
|
2561
|
+
}
|
|
2562
|
+
: {}),
|
|
2563
|
+
// A row an import creates stays pending until `settleImportBinding` accepts it.
|
|
2564
|
+
...(input.importReference && !baseSubscription ? { [BILLFN_IMPORT_PENDING_METADATA_KEY]: true } : {})
|
|
2565
|
+
};
|
|
2075
2566
|
const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
|
|
2076
2567
|
const record = {
|
|
2077
2568
|
id: subscriptionId,
|
|
@@ -2101,23 +2592,15 @@ async function applyVerificationState(deps, input) {
|
|
|
2101
2592
|
const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
|
|
2102
2593
|
Object.assign(record, withPendingReplacements(record.metadata, pending));
|
|
2103
2594
|
// Every update is ordered against the row it was computed from, so a provider-state time and the
|
|
2104
|
-
// pending references it clears always come from the row it replaces.
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
where: [
|
|
2108
|
-
{ field: 'id', operator: 'eq', value: baseSubscription.id },
|
|
2109
|
-
{ field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
|
|
2110
|
-
],
|
|
2111
|
-
data: record,
|
|
2112
|
-
namespace: deps.namespace
|
|
2113
|
-
});
|
|
2114
|
-
if (updated === 1) {
|
|
2595
|
+
// pending references it clears always come from the row it replaces. A re-import of an accepted row
|
|
2596
|
+
// records its audit first.
|
|
2597
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, record, input.importAudit)) {
|
|
2115
2598
|
subscription = record;
|
|
2116
2599
|
break;
|
|
2117
2600
|
}
|
|
2118
2601
|
// A lost race leaves its records: a reference the winner keeps is held, so its events still link to it.
|
|
2119
|
-
const current = await
|
|
2120
|
-
if (!current) {
|
|
2602
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2603
|
+
if (!current && !input.importReference) {
|
|
2121
2604
|
throw createBillFnError({
|
|
2122
2605
|
code: 'BILLFN_CONFLICT',
|
|
2123
2606
|
message: 'Subscription changed while applying provider event state'
|
|
@@ -2153,6 +2636,16 @@ async function applyVerificationState(deps, input) {
|
|
|
2153
2636
|
message: 'Unable to apply provider event state after multiple retries'
|
|
2154
2637
|
});
|
|
2155
2638
|
}
|
|
2639
|
+
// Whether the row carries this call's state; otherwise it publishes the row's own plan, never `input.plan`.
|
|
2640
|
+
let ownState = !liveStateSuperseded;
|
|
2641
|
+
// Whether this call records the row's lifecycle event (an import that accepts another run's state records it).
|
|
2642
|
+
let recordLifecycle = ownState;
|
|
2643
|
+
if (input.importReference) {
|
|
2644
|
+
const settled = await settleImportBinding(deps, subscription, input.importReference, input.billingAccount.id, input.importAudit);
|
|
2645
|
+
subscription = settled.subscription;
|
|
2646
|
+
ownState = ownState && settled.ownState;
|
|
2647
|
+
recordLifecycle = ownState || settled.acceptedHere;
|
|
2648
|
+
}
|
|
2156
2649
|
const checkoutSession = input.checkoutSession
|
|
2157
2650
|
? await updateCheckoutSession(deps, input.checkoutSession.checkoutSessionId, {
|
|
2158
2651
|
status: input.state.checkoutStatus,
|
|
@@ -2162,9 +2655,10 @@ async function applyVerificationState(deps, input) {
|
|
|
2162
2655
|
metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
|
|
2163
2656
|
})
|
|
2164
2657
|
: input.checkoutSession;
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2658
|
+
// A superseded read leaves the subscription's state, so its entitlements stand and no lifecycle event is recorded;
|
|
2659
|
+
// an import that ends on another run's state publishes that state's plan.
|
|
2660
|
+
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, ownState ? input.plan : requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
|
|
2661
|
+
if (!recordLifecycle) {
|
|
2168
2662
|
return {
|
|
2169
2663
|
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2170
2664
|
subscription,
|
|
@@ -2172,7 +2666,6 @@ async function applyVerificationState(deps, input) {
|
|
|
2172
2666
|
providerStateRaced
|
|
2173
2667
|
};
|
|
2174
2668
|
}
|
|
2175
|
-
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, input.plan, subscription, timestamp);
|
|
2176
2669
|
await recordBillingEvent(deps, {
|
|
2177
2670
|
// A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
|
|
2178
2671
|
id: input.providerEventId
|
|
@@ -2198,6 +2691,17 @@ async function applyVerificationState(deps, input) {
|
|
|
2198
2691
|
providerStateRaced
|
|
2199
2692
|
};
|
|
2200
2693
|
}
|
|
2694
|
+
/**
|
|
2695
|
+
* The row to retry a lost ordered write against: the row again, or for an
|
|
2696
|
+
* import the row that holds its reference now (null when none does, and the
|
|
2697
|
+
* import creates one), so the import never writes its reference back onto a
|
|
2698
|
+
* row a writer took it from.
|
|
2699
|
+
*/
|
|
2700
|
+
function reloadAfterLostWrite(deps, input, subscription) {
|
|
2701
|
+
return input.importReference
|
|
2702
|
+
? findSubscriptionByProviderRef(deps, input.price.provider, input.importReference)
|
|
2703
|
+
: getSubscriptionById(deps, subscription.id);
|
|
2704
|
+
}
|
|
2201
2705
|
/**
|
|
2202
2706
|
* The provider-state time a write saves: the event's time, or for live state
|
|
2203
2707
|
* the read start (a committed mutation: its commit), never moving the stored
|
|
@@ -2209,19 +2713,22 @@ function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
|
|
|
2209
2713
|
}
|
|
2210
2714
|
/**
|
|
2211
2715
|
* The subscription row after a live provider read that an applied event has
|
|
2212
|
-
* superseded: missing provider references from the read are
|
|
2213
|
-
* state field is kept. Returns null when the read adds
|
|
2716
|
+
* superseded: missing provider references and metadata keys from the read are
|
|
2717
|
+
* added and every state field is kept. Returns null when the read adds neither.
|
|
2214
2718
|
*/
|
|
2215
2719
|
function linkLiveReadReferences(subscription, input, timestamp) {
|
|
2216
2720
|
const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
|
|
2217
2721
|
const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
|
|
2218
2722
|
const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
|
|
2723
|
+
const addsMetadata = Object.keys(input.metadataDefaults ?? {}).some((key) => subscription.metadata?.[key] === undefined);
|
|
2219
2724
|
if (providerSubscriptionId === subscription.providerSubscriptionId &&
|
|
2220
2725
|
providerCheckoutId === subscription.providerCheckoutId &&
|
|
2221
|
-
providerChargeId === subscription.providerChargeId
|
|
2726
|
+
providerChargeId === subscription.providerChargeId &&
|
|
2727
|
+
!addsMetadata) {
|
|
2222
2728
|
return null;
|
|
2223
2729
|
}
|
|
2224
|
-
|
|
2730
|
+
const metadata = addsMetadata ? { ...input.metadataDefaults, ...subscription.metadata } : subscription.metadata;
|
|
2731
|
+
return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, metadata, updatedAt: timestamp };
|
|
2225
2732
|
}
|
|
2226
2733
|
/**
|
|
2227
2734
|
* Links the provider references of an event skipped as older than the
|
|
@@ -2274,6 +2781,28 @@ async function linkableMissingReferences(deps, subscription, state) {
|
|
|
2274
2781
|
}
|
|
2275
2782
|
return data;
|
|
2276
2783
|
}
|
|
2784
|
+
/**
|
|
2785
|
+
* The ordered update of an existing row that `applyVerificationState` (state and superseded-read link updates)
|
|
2786
|
+
* and an import's acceptance (`settleImportBinding`) go through. With an import's audit, a write that leaves the
|
|
2787
|
+
* row usable (not pending) records the run's audit for the state it publishes first, so no later failure or
|
|
2788
|
+
* interruption leaves an import's change without its operator. The row an import creates is pending, so it is
|
|
2789
|
+
* audited when this accepts it.
|
|
2790
|
+
*/
|
|
2791
|
+
async function updateSubscriptionAfterImportAudit(deps, current, next, importAudit) {
|
|
2792
|
+
if (importAudit && !isImportPending(next)) {
|
|
2793
|
+
await recordBillingEvent(deps, importAudit(next));
|
|
2794
|
+
}
|
|
2795
|
+
const updated = await deps.db.updateMany({
|
|
2796
|
+
model: TABLES.subscriptions,
|
|
2797
|
+
where: [
|
|
2798
|
+
{ field: 'id', operator: 'eq', value: current.id },
|
|
2799
|
+
{ field: 'updatedAt', operator: 'eq', value: current.updatedAt }
|
|
2800
|
+
],
|
|
2801
|
+
data: next,
|
|
2802
|
+
namespace: deps.namespace
|
|
2803
|
+
});
|
|
2804
|
+
return updated === 1;
|
|
2805
|
+
}
|
|
2277
2806
|
/** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
|
|
2278
2807
|
async function writeOrderedSubscription(deps, current, next) {
|
|
2279
2808
|
try {
|
|
@@ -2376,7 +2905,8 @@ async function recordBillingEvent(deps, event) {
|
|
|
2376
2905
|
});
|
|
2377
2906
|
}
|
|
2378
2907
|
catch (error) {
|
|
2379
|
-
//
|
|
2908
|
+
// Already recorded: a provider-event-keyed entry by an earlier apply of the same event, or a digest-keyed
|
|
2909
|
+
// import event by an earlier write of the same import run and state. The first write wins.
|
|
2380
2910
|
if (!isDuplicateRecordError(error)) {
|
|
2381
2911
|
throw error;
|
|
2382
2912
|
}
|
|
@@ -2536,6 +3066,10 @@ async function appendUsageLedgerEntry(deps, billingAccountId, resource, amount)
|
|
|
2536
3066
|
}
|
|
2537
3067
|
async function resolveProjectionContextForProviderState(deps, input) {
|
|
2538
3068
|
const subscription = await findSubscriptionByProviderRef(deps, input.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutReferenceId);
|
|
3069
|
+
if (isImportPending(subscription)) {
|
|
3070
|
+
// Not usable until its import accepts it: the event is stored unlinked and replayed then.
|
|
3071
|
+
return null;
|
|
3072
|
+
}
|
|
2539
3073
|
if (subscription) {
|
|
2540
3074
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
2541
3075
|
if (!billingAccount) {
|
|
@@ -2694,7 +3228,12 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
|
|
|
2694
3228
|
const metadataBillingAccountId = readBillingAccountMetadata(event, state);
|
|
2695
3229
|
const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2696
3230
|
let reason;
|
|
2697
|
-
|
|
3231
|
+
const importPending = isImportPending(existing);
|
|
3232
|
+
if (importPending) {
|
|
3233
|
+
// Its import replays this event once it accepts the row; a refused import leaves it unlinked.
|
|
3234
|
+
reason = 'subscription_import_pending';
|
|
3235
|
+
}
|
|
3236
|
+
else if (existing) {
|
|
2698
3237
|
// A link that committed after this event's first lookup is applied to like any linked reference.
|
|
2699
3238
|
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
2700
3239
|
provider: providerName,
|
|
@@ -2726,10 +3265,11 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
|
|
|
2726
3265
|
reason,
|
|
2727
3266
|
metadataBillingAccountId
|
|
2728
3267
|
});
|
|
2729
|
-
if (existing) {
|
|
3268
|
+
if (existing && !importPending) {
|
|
2730
3269
|
return { handled: null };
|
|
2731
3270
|
}
|
|
2732
|
-
// A link that committed while this event was being stored would have missed it;
|
|
3271
|
+
// A link (or an import's acceptance) that committed while this event was being stored would have missed it;
|
|
3272
|
+
// replay it now. The replay skips a row still pending.
|
|
2733
3273
|
const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2734
3274
|
if (linked) {
|
|
2735
3275
|
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
@@ -2878,6 +3418,10 @@ async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
|
|
|
2878
3418
|
*/
|
|
2879
3419
|
async function replayUnlinkedWebhookEvents(deps, subscription) {
|
|
2880
3420
|
const result = { applied: 0, skipped: 0, pending: 0 };
|
|
3421
|
+
if (isImportPending(subscription)) {
|
|
3422
|
+
// A pending import row is not linked yet: its import replays the events once it accepts the row.
|
|
3423
|
+
return result;
|
|
3424
|
+
}
|
|
2881
3425
|
const queue = createUnlinkedReplayQueue(deps, subscription.provider);
|
|
2882
3426
|
let latest = subscription;
|
|
2883
3427
|
await queue.discover(subscription);
|