@billfn/core 0.2.0 → 0.3.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/service.d.ts +19 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +545 -48
- package/dist/service.js.map +1 -1
- package/dist/types.d.ts +92 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
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';
|
|
@@ -33,6 +34,23 @@ const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
|
|
|
33
34
|
const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
|
|
34
35
|
/** Provider metadata key naming the billing account that owns a provider record. */
|
|
35
36
|
const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
|
|
37
|
+
/**
|
|
38
|
+
* Subscription metadata key set when an operator imported the subscription:
|
|
39
|
+
* when it was first imported. Its binding to the billing account is the
|
|
40
|
+
* operator's, so a provider adapter does not expect the BillFn ownership
|
|
41
|
+
* metadata a subscription created outside BillFn lacks.
|
|
42
|
+
*/
|
|
43
|
+
export const BILLFN_IMPORTED_AT_METADATA_KEY = 'billfnImportedAt';
|
|
44
|
+
/**
|
|
45
|
+
* Subscription metadata key set on the row an import creates until its
|
|
46
|
+
* binding is accepted (`settleImportBinding`). A pending row is not usable:
|
|
47
|
+
* webhooks and replays leave events for it unlinked, other writes refuse it
|
|
48
|
+
* (retryably) and subject, account and sync lookups skip it. A refusal deletes
|
|
49
|
+
* it, on the import's first run or on any rerun, so it never becomes durable.
|
|
50
|
+
*/
|
|
51
|
+
export const BILLFN_IMPORT_PENDING_METADATA_KEY = 'billfnImportPending';
|
|
52
|
+
/** `details.reason` of the retryable `BILLFN_CONFLICT` an import raises when its replay did not finish. */
|
|
53
|
+
export const BILLFN_IMPORT_REPLAY_UNFINISHED_REASON = 'import_replay_unfinished';
|
|
36
54
|
/** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
|
|
37
55
|
const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
|
|
38
56
|
/**
|
|
@@ -896,6 +914,142 @@ function createBillFnServiceWithInternals(config) {
|
|
|
896
914
|
message: 'No restored purchase could be linked to the resolved billing account'
|
|
897
915
|
});
|
|
898
916
|
},
|
|
917
|
+
async importSubscription(input) {
|
|
918
|
+
const { provider: providerName, reference, operator } = readImportRequest(input);
|
|
919
|
+
const plan = requirePlan(deps.catalog, input.planKey);
|
|
920
|
+
if (!plan.prices.some((price) => price.provider === providerName && price.kind === 'subscription')) {
|
|
921
|
+
throw createBillFnError({
|
|
922
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
923
|
+
message: `Plan ${plan.planKey} has no ${providerName} subscription price`
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
const provider = requireProvider(deps, providerName, 'importSubscription');
|
|
927
|
+
// A reference replaced by a subscription write, or bound to another billing account, fails before the provider is read.
|
|
928
|
+
// A pending row (an import not yet accepted, perhaps interrupted) is checked like an unbound reference and
|
|
929
|
+
// removed when the reference is retired, so a rerun of a refused import converges to no binding.
|
|
930
|
+
const bound = await findSubscriptionByProviderRef(deps, providerName, reference);
|
|
931
|
+
if (!bound || isImportPending(bound)) {
|
|
932
|
+
await refuseRetiredImportReference(deps, providerName, reference, bound);
|
|
933
|
+
}
|
|
934
|
+
if (bound && bound.billingAccountId !== input.billingAccountId) {
|
|
935
|
+
throw importBoundElsewhere(providerName);
|
|
936
|
+
}
|
|
937
|
+
// Resolved after the checks above, so an import they refuse never creates the subject's billing account;
|
|
938
|
+
// one refused after the provider read keeps it.
|
|
939
|
+
const billingAccount = await resolveImportBillingAccount(deps, input);
|
|
940
|
+
// Events that occurred before this live read are already reflected in its state.
|
|
941
|
+
const importObservedAt = toIsoString(deps.now());
|
|
942
|
+
const state = await readImportState(provider, providerName, { providerReference: reference, billingAccount, plan });
|
|
943
|
+
if (!state) {
|
|
944
|
+
throw createBillFnError({
|
|
945
|
+
code: 'BILLFN_NOT_FOUND',
|
|
946
|
+
message: 'Provider did not return the subscription to import'
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
if (state.providerSubscriptionId !== reference) {
|
|
950
|
+
throw createBillFnError({
|
|
951
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
952
|
+
message: 'The provider record does not match the imported reference'
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
const selection = requireImportPrice(plan, providerName, state);
|
|
956
|
+
// One audit event per import run and state it records, never changed once written. The run is the operator's
|
|
957
|
+
// request (a rerun of it that records a state it already recorded adds none, even after another state) or,
|
|
958
|
+
// without a requestId, this call. The state is every field the event shows and whether the row carries the
|
|
959
|
+
// import marker (not its value, which a rerun that adds it sets to its own read time).
|
|
960
|
+
const run = operator.requestId ? ['request', operator.requestId] : ['call', deps.generateId('run')];
|
|
961
|
+
const importAudit = (subscription) => ({
|
|
962
|
+
id: `evt_import_${createHash('sha256')
|
|
963
|
+
.update(JSON.stringify([
|
|
964
|
+
providerName,
|
|
965
|
+
reference,
|
|
966
|
+
billingAccount.id,
|
|
967
|
+
operator.id,
|
|
968
|
+
operator.type ?? null,
|
|
969
|
+
...run,
|
|
970
|
+
plan.planKey,
|
|
971
|
+
input.reason ?? null,
|
|
972
|
+
subscription.id,
|
|
973
|
+
subscription.planKey,
|
|
974
|
+
subscription.priceId,
|
|
975
|
+
subscription.status,
|
|
976
|
+
subscription.currentPeriodStart ?? null,
|
|
977
|
+
subscription.currentPeriodEnd ?? null,
|
|
978
|
+
subscription.autoRenew,
|
|
979
|
+
subscription.metadata?.[BILLFN_IMPORTED_AT_METADATA_KEY] !== undefined
|
|
980
|
+
]))
|
|
981
|
+
.digest('base64url')}`,
|
|
982
|
+
billingAccountId: billingAccount.id,
|
|
983
|
+
type: 'billfn.subscription.imported',
|
|
984
|
+
payload: {
|
|
985
|
+
subscriptionId: subscription.id,
|
|
986
|
+
provider: providerName,
|
|
987
|
+
providerReference: reference,
|
|
988
|
+
planKey: subscription.planKey,
|
|
989
|
+
priceId: subscription.priceId,
|
|
990
|
+
status: subscription.status,
|
|
991
|
+
currentPeriodStart: subscription.currentPeriodStart ?? null,
|
|
992
|
+
currentPeriodEnd: subscription.currentPeriodEnd ?? null,
|
|
993
|
+
autoRenew: subscription.autoRenew,
|
|
994
|
+
operator,
|
|
995
|
+
...(input.reason ? { reason: input.reason } : {})
|
|
996
|
+
},
|
|
997
|
+
createdAt: toIsoString(deps.now())
|
|
998
|
+
});
|
|
999
|
+
let applied;
|
|
1000
|
+
try {
|
|
1001
|
+
// Racing imports end with one row: the loser of the unique reference insert applies an ordered update,
|
|
1002
|
+
// or fails as bound elsewhere when the winner bound another billing account.
|
|
1003
|
+
applied = await applyVerificationState(deps, {
|
|
1004
|
+
billingAccount,
|
|
1005
|
+
plan: selection.plan,
|
|
1006
|
+
price: selection.price,
|
|
1007
|
+
state,
|
|
1008
|
+
liveStateObservedAt: importObservedAt,
|
|
1009
|
+
// A rerun or a racing import keeps the first import's time.
|
|
1010
|
+
metadataDefaults: { [BILLFN_IMPORTED_AT_METADATA_KEY]: importObservedAt },
|
|
1011
|
+
// A write may retire the reference while the provider is read (the check above ran before the read).
|
|
1012
|
+
importReference: reference,
|
|
1013
|
+
importAudit
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
if (hasErrorCode(error, 'BILLFN_CONFLICT') && (await isBoundElsewhere(deps, providerName, state, billingAccount.id))) {
|
|
1018
|
+
throw importBoundElsewhere(providerName);
|
|
1019
|
+
}
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
// Each write that left the row usable recorded its state's audit first. A run that wrote nothing (a read an
|
|
1023
|
+
// event superseded, or another run's accepted row) records the state it applied here, before the replay
|
|
1024
|
+
// changes anything, so a replay that does not finish still leaves this operator's event.
|
|
1025
|
+
await recordBillingEvent(deps, importAudit(applied.subscription));
|
|
1026
|
+
// A replay that does not finish fails the import; calling it again finishes the replay.
|
|
1027
|
+
try {
|
|
1028
|
+
await replayUnlinkedWebhookEventsOrThrow(deps, applied.subscription);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
if (isReconciliationLeaseConflict(error)) {
|
|
1032
|
+
throw importReplayUnfinished();
|
|
1033
|
+
}
|
|
1034
|
+
throw error;
|
|
1035
|
+
}
|
|
1036
|
+
const refreshed = await refreshProjectionAfterReplay(deps, applied);
|
|
1037
|
+
// The event returned is the one for the state returned (after the replay), recorded here when the replay
|
|
1038
|
+
// changed it. The first write wins, so a rerun returns it as recorded.
|
|
1039
|
+
const recorded = importAudit(refreshed.subscription);
|
|
1040
|
+
await recordBillingEvent(deps, recorded);
|
|
1041
|
+
const billingEvent = (await deps.db.findOne({
|
|
1042
|
+
model: TABLES.billingEvents,
|
|
1043
|
+
where: [{ field: 'id', operator: 'eq', value: recorded.id }],
|
|
1044
|
+
namespace: deps.namespace
|
|
1045
|
+
})) ?? recorded;
|
|
1046
|
+
deps.metrics.track('subscription.imported', { provider: providerName });
|
|
1047
|
+
return ok({
|
|
1048
|
+
subscription: refreshed.subscription,
|
|
1049
|
+
entitlements: refreshed.entitlements,
|
|
1050
|
+
billingEvent
|
|
1051
|
+
});
|
|
1052
|
+
},
|
|
899
1053
|
async getEntitlements(subject) {
|
|
900
1054
|
const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
|
|
901
1055
|
const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
|
|
@@ -1427,7 +1581,7 @@ async function processReconciliationJob(deps, job, assertLease) {
|
|
|
1427
1581
|
}
|
|
1428
1582
|
async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
|
|
1429
1583
|
const subscription = await getSubscriptionById(deps, subscriptionId);
|
|
1430
|
-
if (!subscription) {
|
|
1584
|
+
if (!subscription || isImportPending(subscription)) {
|
|
1431
1585
|
return;
|
|
1432
1586
|
}
|
|
1433
1587
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
@@ -1623,6 +1777,287 @@ function requireProvider(deps, providerName, capability) {
|
|
|
1623
1777
|
}
|
|
1624
1778
|
return provider;
|
|
1625
1779
|
}
|
|
1780
|
+
function readImportRequest(input) {
|
|
1781
|
+
const invalid = (message) => createBillFnError({ code: 'BILLFN_VALIDATION_ERROR', message });
|
|
1782
|
+
const optionalReference = (value, name) => {
|
|
1783
|
+
if (value === undefined) {
|
|
1784
|
+
return undefined;
|
|
1785
|
+
}
|
|
1786
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value !== value.trim()) {
|
|
1787
|
+
throw invalid(`${name} must be a non-empty string without surrounding whitespace`);
|
|
1788
|
+
}
|
|
1789
|
+
return value;
|
|
1790
|
+
};
|
|
1791
|
+
const providerSubscriptionId = optionalReference(input.providerSubscriptionId, 'providerSubscriptionId');
|
|
1792
|
+
const originalTransactionId = optionalReference(input.originalTransactionId, 'originalTransactionId');
|
|
1793
|
+
if (originalTransactionId !== undefined && input.provider !== 'apple') {
|
|
1794
|
+
throw invalid('originalTransactionId identifies Apple subscriptions only');
|
|
1795
|
+
}
|
|
1796
|
+
if (providerSubscriptionId !== undefined && originalTransactionId !== undefined && providerSubscriptionId !== originalTransactionId) {
|
|
1797
|
+
throw invalid('providerSubscriptionId and originalTransactionId name different subscriptions');
|
|
1798
|
+
}
|
|
1799
|
+
const reference = providerSubscriptionId ?? originalTransactionId;
|
|
1800
|
+
if (reference === undefined) {
|
|
1801
|
+
throw invalid('Importing a subscription requires providerSubscriptionId or originalTransactionId');
|
|
1802
|
+
}
|
|
1803
|
+
if (typeof input.billingAccountId !== 'string' || input.billingAccountId.length === 0) {
|
|
1804
|
+
throw invalid('billingAccountId must be a non-empty string');
|
|
1805
|
+
}
|
|
1806
|
+
if (typeof input.planKey !== 'string' || input.planKey.length === 0) {
|
|
1807
|
+
throw invalid('planKey must be a non-empty string');
|
|
1808
|
+
}
|
|
1809
|
+
const operator = input.operator;
|
|
1810
|
+
if (!operator || typeof operator.id !== 'string' || operator.id.length === 0) {
|
|
1811
|
+
throw invalid('Importing a subscription requires the operator identity');
|
|
1812
|
+
}
|
|
1813
|
+
if (input.reason !== undefined && typeof input.reason !== 'string') {
|
|
1814
|
+
throw invalid('reason must be a string');
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
provider: input.provider,
|
|
1818
|
+
reference,
|
|
1819
|
+
operator: {
|
|
1820
|
+
id: operator.id,
|
|
1821
|
+
...(typeof operator.type === 'string' ? { type: operator.type } : {}),
|
|
1822
|
+
...(typeof operator.requestId === 'string' ? { requestId: operator.requestId } : {})
|
|
1823
|
+
}
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Reads the provider record to import. A BillFn error from the adapter keeps its classification; any other failure
|
|
1828
|
+
* (the provider was not reached, or the adapter failed without classifying it) is a retryable provider error whose
|
|
1829
|
+
* message names only the provider, so no raw error text reaches the operator.
|
|
1830
|
+
*/
|
|
1831
|
+
async function readImportState(provider, providerName, input) {
|
|
1832
|
+
try {
|
|
1833
|
+
return await provider.importSubscription(input);
|
|
1834
|
+
}
|
|
1835
|
+
catch (error) {
|
|
1836
|
+
const code = error?.code;
|
|
1837
|
+
if (typeof code === 'string' && code.startsWith('BILLFN_')) {
|
|
1838
|
+
throw error;
|
|
1839
|
+
}
|
|
1840
|
+
throw createBillFnError({
|
|
1841
|
+
code: 'BILLFN_PROVIDER_ERROR',
|
|
1842
|
+
message: `Provider ${providerName} could not be read for the imported subscription`
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
/** The billing account an import binds to: created for the subject when one is given, otherwise existing. */
|
|
1847
|
+
async function resolveImportBillingAccount(deps, input) {
|
|
1848
|
+
if (input.subject) {
|
|
1849
|
+
const subject = requireSubject(input.subject);
|
|
1850
|
+
const resolved = await resolveBillingAccountReference(deps, subject);
|
|
1851
|
+
if (resolved.billingAccountId !== input.billingAccountId) {
|
|
1852
|
+
throw createBillFnError({
|
|
1853
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1854
|
+
message: 'The subject resolves to a different billing account'
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
return (await getOrCreateBillingAccount(deps, subject)).billingAccount;
|
|
1858
|
+
}
|
|
1859
|
+
const billingAccount = await getBillingAccountById(deps, input.billingAccountId);
|
|
1860
|
+
if (!billingAccount) {
|
|
1861
|
+
throw createBillFnError({
|
|
1862
|
+
code: 'BILLFN_NOT_FOUND',
|
|
1863
|
+
message: 'Billing account not found'
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
return billingAccount;
|
|
1867
|
+
}
|
|
1868
|
+
/**
|
|
1869
|
+
* The plan's subscription price the provider record's product names. Every reference (the adapter's
|
|
1870
|
+
* `providerProductId`, then the record's `product_id`/`productId`) is a provider product id here, so it is matched
|
|
1871
|
+
* only against `providerProductId`, never against a catalog `priceId` another plan's product could equal.
|
|
1872
|
+
*/
|
|
1873
|
+
function requireImportPrice(plan, provider, state) {
|
|
1874
|
+
const raw = asRecord(state.raw);
|
|
1875
|
+
const records = [raw, asRecord(raw?.transaction)];
|
|
1876
|
+
const references = [
|
|
1877
|
+
state.providerProductId,
|
|
1878
|
+
...records.flatMap((record) => (record ? ['product_id', 'productId'].map((key) => readString(record, key)) : []))
|
|
1879
|
+
].filter((reference) => Boolean(reference));
|
|
1880
|
+
const prices = plan.prices.filter((price) => price.provider === provider && price.kind === 'subscription');
|
|
1881
|
+
for (const reference of references) {
|
|
1882
|
+
const price = prices.find((candidate) => candidate.providerProductId === reference);
|
|
1883
|
+
if (price) {
|
|
1884
|
+
return { plan, price };
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
throw createBillFnError({
|
|
1888
|
+
code: 'BILLFN_VALIDATION_ERROR',
|
|
1889
|
+
message: `The provider subscription's product is not a ${provider} subscription price of plan ${plan.planKey}`,
|
|
1890
|
+
details: { planKey: plan.planKey, providerProductIds: references }
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
function importBoundElsewhere(provider) {
|
|
1894
|
+
return createBillFnError({
|
|
1895
|
+
code: 'BILLFN_CONFLICT',
|
|
1896
|
+
message: `The ${provider} subscription is already bound to a different billing account`
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
/** Whether a subscription of another billing account holds a reference the provider record names. */
|
|
1900
|
+
async function isBoundElsewhere(deps, provider, state, billingAccountId) {
|
|
1901
|
+
const holder = await findSubscriptionByProviderRef(deps, provider, state.providerSubscriptionId, state.providerChargeId);
|
|
1902
|
+
return Boolean(holder && holder.billingAccountId !== billingAccountId);
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The import is bound and recorded, but another run holds the reference's
|
|
1906
|
+
* unlinked events (including a run interrupted before it released them) or the
|
|
1907
|
+
* replay reached its bound. Unlike a bound-elsewhere conflict it is retryable:
|
|
1908
|
+
* calling import again, after the holder's lease expires, finishes the replay.
|
|
1909
|
+
*/
|
|
1910
|
+
function importReplayUnfinished() {
|
|
1911
|
+
return createBillFnError({
|
|
1912
|
+
code: 'BILLFN_CONFLICT',
|
|
1913
|
+
message: 'The import is recorded but replaying its unlinked webhook events did not finish; import again to finish it',
|
|
1914
|
+
details: { reason: BILLFN_IMPORT_REPLAY_UNFINISHED_REASON },
|
|
1915
|
+
retryable: true
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
function importReferenceReplaced() {
|
|
1919
|
+
return createBillFnError({
|
|
1920
|
+
code: 'BILLFN_CONFLICT',
|
|
1921
|
+
message: 'The provider reference was replaced by another subscription and cannot be imported'
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Refuses an import of a reference a subscription write retired: deletes
|
|
1926
|
+
* `holder` when it is a pending import row holding the reference (whichever
|
|
1927
|
+
* import run created it), then fails with a non-retryable `BILLFN_CONFLICT`.
|
|
1928
|
+
* An accepted row is never deleted: the delete is ordered against a fresh
|
|
1929
|
+
* read that is still pending, so a stale `holder` another run has accepted
|
|
1930
|
+
* since survives.
|
|
1931
|
+
*/
|
|
1932
|
+
async function refuseRetiredImportReference(deps, provider, reference, holder) {
|
|
1933
|
+
if (!(await isReplacedProviderReference(deps, provider, reference, holder?.id))) {
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
const stored = holder && isImportPending(holder) ? await getSubscriptionById(deps, holder.id) : null;
|
|
1937
|
+
if (stored && isImportPending(stored) && stored.providerSubscriptionId === reference) {
|
|
1938
|
+
await deps.db.deleteMany({
|
|
1939
|
+
model: TABLES.subscriptions,
|
|
1940
|
+
where: [
|
|
1941
|
+
{ field: 'id', operator: 'eq', value: stored.id },
|
|
1942
|
+
{ field: 'providerSubscriptionId', operator: 'eq', value: reference },
|
|
1943
|
+
{ field: 'updatedAt', operator: 'eq', value: stored.updatedAt }
|
|
1944
|
+
],
|
|
1945
|
+
namespace: deps.namespace
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
deps.metrics.track('subscription.import.replaced_reference_refused', { provider });
|
|
1949
|
+
throw importReferenceReplaced();
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* After an import's write to `subscription`, which holds `reference`: refuses
|
|
1953
|
+
* the binding when a write of another subscription retired the reference, or
|
|
1954
|
+
* accepts it by clearing the pending marker, and returns the accepted row:
|
|
1955
|
+
* `ownState` when that row carries this write's state, and `acceptedHere` when
|
|
1956
|
+
* this call cleared its pending marker. A lost acceptance write re-finds the
|
|
1957
|
+
* row, which then carries another run's state (accepted by it or here), so the
|
|
1958
|
+
* caller publishes that row's plan, never its own. Every row, the first and
|
|
1959
|
+
* each reload, is checked the same way before it is accepted or returned: it
|
|
1960
|
+
* holds the reference, belongs to `billingAccountId` (another account's row
|
|
1961
|
+
* fails as bound elsewhere, untouched) and its reference is not retired.
|
|
1962
|
+
* Nothing uses a pending row, so no entitlement or lifecycle event exists for
|
|
1963
|
+
* it until this accepts it. The import's audit event (`audit`) for the row it
|
|
1964
|
+
* accepts is recorded just before each acceptance write, so an accepted
|
|
1965
|
+
* binding always has it (an event for a write that then does not land stays);
|
|
1966
|
+
* a refusal deletes the row, and an interrupted run leaves it pending for the
|
|
1967
|
+
* rerun to settle.
|
|
1968
|
+
*
|
|
1969
|
+
* Why the check after the write is enough: a reference is held by at most one
|
|
1970
|
+
* row (unique index), so a writer that retired it took it off its row before
|
|
1971
|
+
* this write held it, and recorded it before that, or saved it pending in that
|
|
1972
|
+
* same write. Read pending, then recorded, now, one of the reads sees it. Every
|
|
1973
|
+
* import that holds the row therefore reaches the same answer.
|
|
1974
|
+
*/
|
|
1975
|
+
async function settleImportBinding(deps, subscription, reference, billingAccountId, audit) {
|
|
1976
|
+
let current = subscription;
|
|
1977
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
1978
|
+
// Only the first attempt holds this run's write; a reload holds whatever wrote the row after it.
|
|
1979
|
+
const ownState = attempt === 0;
|
|
1980
|
+
if (current?.providerSubscriptionId !== reference) {
|
|
1981
|
+
throw createBillFnError({
|
|
1982
|
+
code: 'BILLFN_CONFLICT',
|
|
1983
|
+
message: 'The imported subscription changed while its import was accepted',
|
|
1984
|
+
retryable: true
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
if (current.billingAccountId !== billingAccountId) {
|
|
1988
|
+
throw importBoundElsewhere(current.provider);
|
|
1989
|
+
}
|
|
1990
|
+
await refuseRetiredImportReference(deps, current.provider, reference, current);
|
|
1991
|
+
if (!isImportPending(current)) {
|
|
1992
|
+
return { subscription: current, ownState, acceptedHere: false };
|
|
1993
|
+
}
|
|
1994
|
+
const accepted = {
|
|
1995
|
+
...current,
|
|
1996
|
+
metadata: withoutImportPending(current.metadata),
|
|
1997
|
+
updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
|
|
1998
|
+
};
|
|
1999
|
+
if (await updateSubscriptionAfterImportAudit(deps, current, accepted, audit)) {
|
|
2000
|
+
return { subscription: accepted, ownState, acceptedHere: true };
|
|
2001
|
+
}
|
|
2002
|
+
current = await findSubscriptionByProviderRef(deps, subscription.provider, reference);
|
|
2003
|
+
await Promise.resolve();
|
|
2004
|
+
}
|
|
2005
|
+
throw createBillFnError({
|
|
2006
|
+
code: 'BILLFN_CONFLICT',
|
|
2007
|
+
message: 'Unable to accept the imported subscription after multiple retries',
|
|
2008
|
+
retryable: true
|
|
2009
|
+
});
|
|
2010
|
+
}
|
|
2011
|
+
/** Whether the row is an import's binding not yet accepted (see `BILLFN_IMPORT_PENDING_METADATA_KEY`). */
|
|
2012
|
+
function isImportPending(subscription) {
|
|
2013
|
+
return subscription?.metadata?.[BILLFN_IMPORT_PENDING_METADATA_KEY] === true;
|
|
2014
|
+
}
|
|
2015
|
+
function withoutImportPending(metadata) {
|
|
2016
|
+
if (!metadata) {
|
|
2017
|
+
return metadata;
|
|
2018
|
+
}
|
|
2019
|
+
const { [BILLFN_IMPORT_PENDING_METADATA_KEY]: _pending, ...rest } = metadata;
|
|
2020
|
+
return rest;
|
|
2021
|
+
}
|
|
2022
|
+
/** A write other than an import's reached a pending import row: retried once the import settles it. */
|
|
2023
|
+
function importPendingConflict() {
|
|
2024
|
+
return createBillFnError({
|
|
2025
|
+
code: 'BILLFN_CONFLICT',
|
|
2026
|
+
message: 'The subscription is being imported',
|
|
2027
|
+
details: { reason: 'subscription_import_pending' },
|
|
2028
|
+
retryable: true
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Whether a subscription write dropped this reference (still pending on the
|
|
2033
|
+
* subscription, or recorded). A writer records the reference before it clears
|
|
2034
|
+
* pending, so pending is read first, as `namesReplacedReference` does: a writer
|
|
2035
|
+
* that clears pending between the reads has already recorded it. `holderId`,
|
|
2036
|
+
* the row that holds the reference now, is not counted: a reference it dropped
|
|
2037
|
+
* and holds again, or one a write that lost its race on it recorded, is held.
|
|
2038
|
+
*/
|
|
2039
|
+
async function isReplacedProviderReference(deps, provider, reference, holderId) {
|
|
2040
|
+
const pending = await deps.db.findMany({
|
|
2041
|
+
model: TABLES.subscriptions,
|
|
2042
|
+
where: [
|
|
2043
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2044
|
+
{ field: 'replacedReferencesPending', operator: 'eq', value: true }
|
|
2045
|
+
],
|
|
2046
|
+
namespace: deps.namespace
|
|
2047
|
+
});
|
|
2048
|
+
if (pending.some((subscription) => subscription.id !== holderId && pendingReplacedReferencesOf(subscription).includes(reference))) {
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
const replaced = await deps.db.findOne({
|
|
2052
|
+
model: TABLES.replacedProviderReferences,
|
|
2053
|
+
where: [
|
|
2054
|
+
{ field: 'provider', operator: 'eq', value: provider },
|
|
2055
|
+
{ field: 'providerReference', operator: 'eq', value: reference }
|
|
2056
|
+
],
|
|
2057
|
+
namespace: deps.namespace
|
|
2058
|
+
});
|
|
2059
|
+
return Boolean(replaced && replaced.subscriptionId !== holderId);
|
|
2060
|
+
}
|
|
1626
2061
|
function requireSubject(subject) {
|
|
1627
2062
|
if (!subject) {
|
|
1628
2063
|
throw createBillFnError({
|
|
@@ -1678,7 +2113,7 @@ async function getSubscriptionById(deps, id) {
|
|
|
1678
2113
|
}
|
|
1679
2114
|
async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId) {
|
|
1680
2115
|
if (subscriptionId) {
|
|
1681
|
-
|
|
2116
|
+
const subscription = await deps.db.findOne({
|
|
1682
2117
|
model: TABLES.subscriptions,
|
|
1683
2118
|
where: [
|
|
1684
2119
|
{ field: 'id', operator: 'eq', value: subscriptionId },
|
|
@@ -1686,6 +2121,7 @@ async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId)
|
|
|
1686
2121
|
],
|
|
1687
2122
|
namespace: deps.namespace
|
|
1688
2123
|
});
|
|
2124
|
+
return isImportPending(subscription) ? null : subscription;
|
|
1689
2125
|
}
|
|
1690
2126
|
return findActiveSubscription(deps, billingAccountId);
|
|
1691
2127
|
}
|
|
@@ -1699,7 +2135,8 @@ async function findActiveSubscription(deps, billingAccountId) {
|
|
|
1699
2135
|
orderBy: [{ field: 'updatedAt', direction: 'desc' }],
|
|
1700
2136
|
namespace: deps.namespace
|
|
1701
2137
|
});
|
|
1702
|
-
|
|
2138
|
+
// A pending import row is not the account's until its import accepts it.
|
|
2139
|
+
return candidates.find((candidate) => !isImportPending(candidate)) ?? null;
|
|
1703
2140
|
}
|
|
1704
2141
|
async function findSubscriptionByProviderRef(deps, provider, providerSubscriptionId, providerChargeId, checkoutReferenceId) {
|
|
1705
2142
|
const whereSets = [];
|
|
@@ -1999,12 +2436,23 @@ async function applyVerificationState(deps, input) {
|
|
|
1999
2436
|
// A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
|
|
2000
2437
|
const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
|
|
2001
2438
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
2439
|
+
if (!input.importReference && isImportPending(baseSubscription)) {
|
|
2440
|
+
// Only the import settles its pending row; anything else applies once it is accepted (or deleted).
|
|
2441
|
+
throw importPendingConflict();
|
|
2442
|
+
}
|
|
2002
2443
|
if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
|
|
2003
2444
|
throw createBillFnError({
|
|
2004
2445
|
code: 'BILLFN_CONFLICT',
|
|
2005
2446
|
message: 'Provider billing reference belongs to a different billing account'
|
|
2006
2447
|
});
|
|
2007
2448
|
}
|
|
2449
|
+
if (input.importReference && baseSubscription && baseSubscription.providerSubscriptionId !== input.importReference) {
|
|
2450
|
+
// Found by another reference the provider record names: an import never moves its reference onto such a row.
|
|
2451
|
+
throw createBillFnError({
|
|
2452
|
+
code: 'BILLFN_CONFLICT',
|
|
2453
|
+
message: 'The provider record names a reference another subscription holds'
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2008
2456
|
const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
|
|
2009
2457
|
if (baseSubscription && order === 'skip') {
|
|
2010
2458
|
// The older event's state is skipped, but a reference it adds still belongs to this subscription.
|
|
@@ -2037,21 +2485,13 @@ async function applyVerificationState(deps, input) {
|
|
|
2037
2485
|
subscription = baseSubscription;
|
|
2038
2486
|
break;
|
|
2039
2487
|
}
|
|
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) {
|
|
2488
|
+
// An import that adds its marker or references to an accepted row records its audit first.
|
|
2489
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, linked, input.importAudit)) {
|
|
2050
2490
|
subscription = linked;
|
|
2051
2491
|
break;
|
|
2052
2492
|
}
|
|
2053
|
-
const current = await
|
|
2054
|
-
if (!current) {
|
|
2493
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2494
|
+
if (!current && !input.importReference) {
|
|
2055
2495
|
throw createBillFnError({
|
|
2056
2496
|
code: 'BILLFN_CONFLICT',
|
|
2057
2497
|
message: 'Subscription changed while applying provider state'
|
|
@@ -2062,16 +2502,20 @@ async function applyVerificationState(deps, input) {
|
|
|
2062
2502
|
continue;
|
|
2063
2503
|
}
|
|
2064
2504
|
// 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);
|
|
2505
|
+
const merged = mergeJson(mergeJson(input.metadataDefaults, baseSubscription?.metadata), input.state.raw);
|
|
2066
2506
|
const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
|
|
2067
2507
|
const providerStateAt = providerStateTimeFor(input.occurredAt, supersededBefore, baseSubscription);
|
|
2068
|
-
const metadata =
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2508
|
+
const metadata = {
|
|
2509
|
+
...mergedMetadata,
|
|
2510
|
+
...(providerStateAt
|
|
2511
|
+
? {
|
|
2512
|
+
[PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
|
|
2513
|
+
...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
|
|
2514
|
+
}
|
|
2515
|
+
: {}),
|
|
2516
|
+
// A row an import creates stays pending until `settleImportBinding` accepts it.
|
|
2517
|
+
...(input.importReference && !baseSubscription ? { [BILLFN_IMPORT_PENDING_METADATA_KEY]: true } : {})
|
|
2518
|
+
};
|
|
2075
2519
|
const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
|
|
2076
2520
|
const record = {
|
|
2077
2521
|
id: subscriptionId,
|
|
@@ -2101,23 +2545,15 @@ async function applyVerificationState(deps, input) {
|
|
|
2101
2545
|
const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
|
|
2102
2546
|
Object.assign(record, withPendingReplacements(record.metadata, pending));
|
|
2103
2547
|
// 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) {
|
|
2548
|
+
// pending references it clears always come from the row it replaces. A re-import of an accepted row
|
|
2549
|
+
// records its audit first.
|
|
2550
|
+
if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, record, input.importAudit)) {
|
|
2115
2551
|
subscription = record;
|
|
2116
2552
|
break;
|
|
2117
2553
|
}
|
|
2118
2554
|
// 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) {
|
|
2555
|
+
const current = await reloadAfterLostWrite(deps, input, baseSubscription);
|
|
2556
|
+
if (!current && !input.importReference) {
|
|
2121
2557
|
throw createBillFnError({
|
|
2122
2558
|
code: 'BILLFN_CONFLICT',
|
|
2123
2559
|
message: 'Subscription changed while applying provider event state'
|
|
@@ -2153,6 +2589,16 @@ async function applyVerificationState(deps, input) {
|
|
|
2153
2589
|
message: 'Unable to apply provider event state after multiple retries'
|
|
2154
2590
|
});
|
|
2155
2591
|
}
|
|
2592
|
+
// Whether the row carries this call's state; otherwise it publishes the row's own plan, never `input.plan`.
|
|
2593
|
+
let ownState = !liveStateSuperseded;
|
|
2594
|
+
// Whether this call records the row's lifecycle event (an import that accepts another run's state records it).
|
|
2595
|
+
let recordLifecycle = ownState;
|
|
2596
|
+
if (input.importReference) {
|
|
2597
|
+
const settled = await settleImportBinding(deps, subscription, input.importReference, input.billingAccount.id, input.importAudit);
|
|
2598
|
+
subscription = settled.subscription;
|
|
2599
|
+
ownState = ownState && settled.ownState;
|
|
2600
|
+
recordLifecycle = ownState || settled.acceptedHere;
|
|
2601
|
+
}
|
|
2156
2602
|
const checkoutSession = input.checkoutSession
|
|
2157
2603
|
? await updateCheckoutSession(deps, input.checkoutSession.checkoutSessionId, {
|
|
2158
2604
|
status: input.state.checkoutStatus,
|
|
@@ -2162,9 +2608,10 @@ async function applyVerificationState(deps, input) {
|
|
|
2162
2608
|
metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
|
|
2163
2609
|
})
|
|
2164
2610
|
: input.checkoutSession;
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2611
|
+
// A superseded read leaves the subscription's state, so its entitlements stand and no lifecycle event is recorded;
|
|
2612
|
+
// an import that ends on another run's state publishes that state's plan.
|
|
2613
|
+
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, ownState ? input.plan : requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
|
|
2614
|
+
if (!recordLifecycle) {
|
|
2168
2615
|
return {
|
|
2169
2616
|
checkoutSession: checkoutSession ?? input.checkoutSession,
|
|
2170
2617
|
subscription,
|
|
@@ -2172,7 +2619,6 @@ async function applyVerificationState(deps, input) {
|
|
|
2172
2619
|
providerStateRaced
|
|
2173
2620
|
};
|
|
2174
2621
|
}
|
|
2175
|
-
const entitlements = await upsertEntitlements(deps, input.billingAccount.id, input.plan, subscription, timestamp);
|
|
2176
2622
|
await recordBillingEvent(deps, {
|
|
2177
2623
|
// A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
|
|
2178
2624
|
id: input.providerEventId
|
|
@@ -2198,6 +2644,17 @@ async function applyVerificationState(deps, input) {
|
|
|
2198
2644
|
providerStateRaced
|
|
2199
2645
|
};
|
|
2200
2646
|
}
|
|
2647
|
+
/**
|
|
2648
|
+
* The row to retry a lost ordered write against: the row again, or for an
|
|
2649
|
+
* import the row that holds its reference now (null when none does, and the
|
|
2650
|
+
* import creates one), so the import never writes its reference back onto a
|
|
2651
|
+
* row a writer took it from.
|
|
2652
|
+
*/
|
|
2653
|
+
function reloadAfterLostWrite(deps, input, subscription) {
|
|
2654
|
+
return input.importReference
|
|
2655
|
+
? findSubscriptionByProviderRef(deps, input.price.provider, input.importReference)
|
|
2656
|
+
: getSubscriptionById(deps, subscription.id);
|
|
2657
|
+
}
|
|
2201
2658
|
/**
|
|
2202
2659
|
* The provider-state time a write saves: the event's time, or for live state
|
|
2203
2660
|
* the read start (a committed mutation: its commit), never moving the stored
|
|
@@ -2209,19 +2666,22 @@ function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
|
|
|
2209
2666
|
}
|
|
2210
2667
|
/**
|
|
2211
2668
|
* 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
|
|
2669
|
+
* superseded: missing provider references and metadata keys from the read are
|
|
2670
|
+
* added and every state field is kept. Returns null when the read adds neither.
|
|
2214
2671
|
*/
|
|
2215
2672
|
function linkLiveReadReferences(subscription, input, timestamp) {
|
|
2216
2673
|
const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
|
|
2217
2674
|
const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
|
|
2218
2675
|
const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
|
|
2676
|
+
const addsMetadata = Object.keys(input.metadataDefaults ?? {}).some((key) => subscription.metadata?.[key] === undefined);
|
|
2219
2677
|
if (providerSubscriptionId === subscription.providerSubscriptionId &&
|
|
2220
2678
|
providerCheckoutId === subscription.providerCheckoutId &&
|
|
2221
|
-
providerChargeId === subscription.providerChargeId
|
|
2679
|
+
providerChargeId === subscription.providerChargeId &&
|
|
2680
|
+
!addsMetadata) {
|
|
2222
2681
|
return null;
|
|
2223
2682
|
}
|
|
2224
|
-
|
|
2683
|
+
const metadata = addsMetadata ? { ...input.metadataDefaults, ...subscription.metadata } : subscription.metadata;
|
|
2684
|
+
return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, metadata, updatedAt: timestamp };
|
|
2225
2685
|
}
|
|
2226
2686
|
/**
|
|
2227
2687
|
* Links the provider references of an event skipped as older than the
|
|
@@ -2274,6 +2734,28 @@ async function linkableMissingReferences(deps, subscription, state) {
|
|
|
2274
2734
|
}
|
|
2275
2735
|
return data;
|
|
2276
2736
|
}
|
|
2737
|
+
/**
|
|
2738
|
+
* The ordered update of an existing row that `applyVerificationState` (state and superseded-read link updates)
|
|
2739
|
+
* and an import's acceptance (`settleImportBinding`) go through. With an import's audit, a write that leaves the
|
|
2740
|
+
* row usable (not pending) records the run's audit for the state it publishes first, so no later failure or
|
|
2741
|
+
* interruption leaves an import's change without its operator. The row an import creates is pending, so it is
|
|
2742
|
+
* audited when this accepts it.
|
|
2743
|
+
*/
|
|
2744
|
+
async function updateSubscriptionAfterImportAudit(deps, current, next, importAudit) {
|
|
2745
|
+
if (importAudit && !isImportPending(next)) {
|
|
2746
|
+
await recordBillingEvent(deps, importAudit(next));
|
|
2747
|
+
}
|
|
2748
|
+
const updated = await deps.db.updateMany({
|
|
2749
|
+
model: TABLES.subscriptions,
|
|
2750
|
+
where: [
|
|
2751
|
+
{ field: 'id', operator: 'eq', value: current.id },
|
|
2752
|
+
{ field: 'updatedAt', operator: 'eq', value: current.updatedAt }
|
|
2753
|
+
],
|
|
2754
|
+
data: next,
|
|
2755
|
+
namespace: deps.namespace
|
|
2756
|
+
});
|
|
2757
|
+
return updated === 1;
|
|
2758
|
+
}
|
|
2277
2759
|
/** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
|
|
2278
2760
|
async function writeOrderedSubscription(deps, current, next) {
|
|
2279
2761
|
try {
|
|
@@ -2376,7 +2858,8 @@ async function recordBillingEvent(deps, event) {
|
|
|
2376
2858
|
});
|
|
2377
2859
|
}
|
|
2378
2860
|
catch (error) {
|
|
2379
|
-
//
|
|
2861
|
+
// Already recorded: a provider-event-keyed entry by an earlier apply of the same event, or a digest-keyed
|
|
2862
|
+
// import event by an earlier write of the same import run and state. The first write wins.
|
|
2380
2863
|
if (!isDuplicateRecordError(error)) {
|
|
2381
2864
|
throw error;
|
|
2382
2865
|
}
|
|
@@ -2536,6 +3019,10 @@ async function appendUsageLedgerEntry(deps, billingAccountId, resource, amount)
|
|
|
2536
3019
|
}
|
|
2537
3020
|
async function resolveProjectionContextForProviderState(deps, input) {
|
|
2538
3021
|
const subscription = await findSubscriptionByProviderRef(deps, input.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutReferenceId);
|
|
3022
|
+
if (isImportPending(subscription)) {
|
|
3023
|
+
// Not usable until its import accepts it: the event is stored unlinked and replayed then.
|
|
3024
|
+
return null;
|
|
3025
|
+
}
|
|
2539
3026
|
if (subscription) {
|
|
2540
3027
|
const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
|
|
2541
3028
|
if (!billingAccount) {
|
|
@@ -2694,7 +3181,12 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
|
|
|
2694
3181
|
const metadataBillingAccountId = readBillingAccountMetadata(event, state);
|
|
2695
3182
|
const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2696
3183
|
let reason;
|
|
2697
|
-
|
|
3184
|
+
const importPending = isImportPending(existing);
|
|
3185
|
+
if (importPending) {
|
|
3186
|
+
// Its import replays this event once it accepts the row; a refused import leaves it unlinked.
|
|
3187
|
+
reason = 'subscription_import_pending';
|
|
3188
|
+
}
|
|
3189
|
+
else if (existing) {
|
|
2698
3190
|
// A link that committed after this event's first lookup is applied to like any linked reference.
|
|
2699
3191
|
const projection = await resolveProjectionContextForProviderState(deps, {
|
|
2700
3192
|
provider: providerName,
|
|
@@ -2726,10 +3218,11 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
|
|
|
2726
3218
|
reason,
|
|
2727
3219
|
metadataBillingAccountId
|
|
2728
3220
|
});
|
|
2729
|
-
if (existing) {
|
|
3221
|
+
if (existing && !importPending) {
|
|
2730
3222
|
return { handled: null };
|
|
2731
3223
|
}
|
|
2732
|
-
// A link that committed while this event was being stored would have missed it;
|
|
3224
|
+
// A link (or an import's acceptance) that committed while this event was being stored would have missed it;
|
|
3225
|
+
// replay it now. The replay skips a row still pending.
|
|
2733
3226
|
const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
|
|
2734
3227
|
if (linked) {
|
|
2735
3228
|
await replayUnlinkedWebhookEventsOrThrow(deps, linked);
|
|
@@ -2878,6 +3371,10 @@ async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
|
|
|
2878
3371
|
*/
|
|
2879
3372
|
async function replayUnlinkedWebhookEvents(deps, subscription) {
|
|
2880
3373
|
const result = { applied: 0, skipped: 0, pending: 0 };
|
|
3374
|
+
if (isImportPending(subscription)) {
|
|
3375
|
+
// A pending import row is not linked yet: its import replays the events once it accepts the row.
|
|
3376
|
+
return result;
|
|
3377
|
+
}
|
|
2881
3378
|
const queue = createUnlinkedReplayQueue(deps, subscription.provider);
|
|
2882
3379
|
let latest = subscription;
|
|
2883
3380
|
await queue.discover(subscription);
|